diff --git a/Makefile b/Makefile
index c7a3eccd..c0dedcdc 100644
--- a/Makefile
+++ b/Makefile
@@ -30,11 +30,13 @@ endif
ifeq ($(CI), true)
@(cd ${currentDir} && npm ci --cache $(NPM_CACHE_DIR) --prefer-offline --unsafe-perm=true)
+ @(cd ${currentDir}/pkg/server/assets && npm ci --cache $(NPM_CACHE_DIR) --prefer-offline --unsafe-perm=true)
@(cd ${currentDir}/web && npm ci --cache $(NPM_CACHE_DIR) --prefer-offline --unsafe-perm=true)
@(cd ${currentDir}/browser && npm ci --cache $(NPM_CACHE_DIR) --prefer-offline --unsafe-perm=true)
@(cd ${currentDir}/jslib && npm ci --cache $(NPM_CACHE_DIR) --prefer-offline --unsafe-perm=true)
else
@(cd ${currentDir} && npm install)
+ @(cd ${currentDir}/pkg/server/assets && npm install)
@(cd ${currentDir}/web && npm install)
@(cd ${currentDir}/browser && npm install)
@(cd ${currentDir}/jslib && npm install)
diff --git a/go.mod b/go.mod
index 14d37869..0150d739 100644
--- a/go.mod
+++ b/go.mod
@@ -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
)
diff --git a/go.sum b/go.sum
index 238bdc3a..41625712 100644
--- a/go.sum
+++ b/go.sum
@@ -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=
diff --git a/pkg/server/.gitignore b/pkg/server/.gitignore
index fa49c96a..9835f3e3 100644
--- a/pkg/server/.gitignore
+++ b/pkg/server/.gitignore
@@ -6,3 +6,4 @@ test-dnote
/dist
/build
server
+/static
diff --git a/pkg/server/api/auth.go b/pkg/server/api/auth.go
deleted file mode 100644
index 56425c87..00000000
--- a/pkg/server/api/auth.go
+++ /dev/null
@@ -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 .
- */
-
-package api
-
-import (
- "encoding/json"
- "net/http"
- "time"
-
- "github.com/dnote/dnote/pkg/server/database"
- "github.com/dnote/dnote/pkg/server/handlers"
- "github.com/dnote/dnote/pkg/server/helpers"
- "github.com/dnote/dnote/pkg/server/log"
- "github.com/dnote/dnote/pkg/server/mailer"
- "github.com/dnote/dnote/pkg/server/session"
- "github.com/dnote/dnote/pkg/server/token"
- "github.com/pkg/errors"
- "golang.org/x/crypto/bcrypt"
-)
-
-// GetMeResponse is the response for getMe endpoint
-type GetMeResponse struct {
- User session.Session `json:"user"`
-}
-
-func (a *API) getMe(w http.ResponseWriter, r *http.Request) {
- user, ok := r.Context().Value(helpers.KeyUser).(database.User)
- if !ok {
- handlers.DoError(w, "No authenticated user found", nil, http.StatusInternalServerError)
- return
- }
-
- var account database.Account
- if err := a.App.DB.Where("user_id = ?", user.ID).First(&account).Error; err != nil {
- handlers.DoError(w, "finding account", err, http.StatusInternalServerError)
- return
- }
-
- tx := a.App.DB.Begin()
- if err := a.App.TouchLastLoginAt(user, tx); err != nil {
- tx.Rollback()
- // In case of an error, gracefully continue to avoid disturbing the service
- log.ErrorWrap(err, "error touching last_login_at")
- }
- tx.Commit()
-
- response := GetMeResponse{
- User: session.New(user, account),
- }
- handlers.RespondJSON(w, http.StatusOK, response)
-}
-
-type createResetTokenPayload struct {
- Email string `json:"email"`
-}
-
-func (a *API) createResetToken(w http.ResponseWriter, r *http.Request) {
- var params createResetTokenPayload
- if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil {
- http.Error(w, "invalid payload", http.StatusBadRequest)
- return
- }
-
- var account database.Account
- conn := a.App.DB.Where("email = ?", params.Email).First(&account)
- if conn.RecordNotFound() {
- return
- }
- if err := conn.Error; err != nil {
- handlers.DoError(w, errors.Wrap(err, "finding account").Error(), nil, http.StatusInternalServerError)
- return
- }
-
- resetToken, err := token.Create(a.App.DB, account.UserID, database.TokenTypeResetPassword)
- if err != nil {
- handlers.DoError(w, errors.Wrap(err, "generating token").Error(), nil, http.StatusInternalServerError)
- return
- }
-
- if err := a.App.SendPasswordResetEmail(account.Email.String, resetToken.Value); err != nil {
- if errors.Cause(err) == mailer.ErrSMTPNotConfigured {
- handlers.RespondInvalidSMTPConfig(w)
- } else {
- handlers.DoError(w, errors.Wrap(err, "sending password reset email").Error(), nil, http.StatusInternalServerError)
- }
-
- return
- }
-}
-
-type resetPasswordPayload struct {
- Password string `json:"password"`
- Token string `json:"token"`
-}
-
-func (a *API) resetPassword(w http.ResponseWriter, r *http.Request) {
- var params resetPasswordPayload
- if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil {
- http.Error(w, "invalid payload", http.StatusBadRequest)
- return
- }
-
- var token database.Token
- conn := a.App.DB.Where("value = ? AND type =? AND used_at IS NULL", params.Token, database.TokenTypeResetPassword).First(&token)
- if conn.RecordNotFound() {
- http.Error(w, "invalid token", http.StatusBadRequest)
- return
- }
- if err := conn.Error; err != nil {
- handlers.DoError(w, errors.Wrap(err, "finding token").Error(), nil, http.StatusInternalServerError)
- return
- }
-
- if token.UsedAt != nil {
- http.Error(w, "invalid token", http.StatusBadRequest)
- return
- }
-
- // Expire after 10 minutes
- if time.Since(token.CreatedAt).Minutes() > 10 {
- http.Error(w, "This link has been expired. Please request a new password reset link.", http.StatusGone)
- return
- }
-
- tx := a.App.DB.Begin()
-
- hashedPassword, err := bcrypt.GenerateFromPassword([]byte(params.Password), bcrypt.DefaultCost)
- if err != nil {
- tx.Rollback()
- handlers.DoError(w, errors.Wrap(err, "hashing password").Error(), nil, http.StatusInternalServerError)
- return
- }
-
- var account database.Account
- if err := a.App.DB.Where("user_id = ?", token.UserID).First(&account).Error; err != nil {
- tx.Rollback()
- handlers.DoError(w, errors.Wrap(err, "finding user").Error(), nil, http.StatusInternalServerError)
- return
- }
-
- if err := tx.Model(&account).Update("password", string(hashedPassword)).Error; err != nil {
- tx.Rollback()
- handlers.DoError(w, errors.Wrap(err, "updating password").Error(), nil, http.StatusInternalServerError)
- return
- }
- if err := tx.Model(&token).Update("used_at", time.Now()).Error; err != nil {
- tx.Rollback()
- handlers.DoError(w, errors.Wrap(err, "updating password reset token").Error(), nil, http.StatusInternalServerError)
- return
- }
-
- 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")
- }
-}
diff --git a/pkg/server/api/auth_test.go b/pkg/server/api/auth_test.go
deleted file mode 100644
index 1e75dd92..00000000
--- a/pkg/server/api/auth_test.go
+++ /dev/null
@@ -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 .
- */
-
-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")
- })
-}
diff --git a/pkg/server/api/helpers.go b/pkg/server/api/helpers.go
deleted file mode 100644
index 60c990fd..00000000
--- a/pkg/server/api/helpers.go
+++ /dev/null
@@ -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 .
- */
-
-package api
-
-import (
- "net/http"
- "strings"
-
- "github.com/dnote/dnote/pkg/server/database"
- "github.com/jinzhu/gorm"
- "github.com/pkg/errors"
-)
-
-func paginate(conn *gorm.DB, page int) *gorm.DB {
- limit := 30
-
- // Paginate
- if page > 0 {
- offset := limit * (page - 1)
- conn = conn.Offset(offset)
- }
-
- conn = conn.Limit(limit)
-
- return conn
-}
-
-func getBookIDs(books []database.Book) []int {
- ret := []int{}
-
- for _, book := range books {
- ret = append(ret, book.ID)
- }
-
- return ret
-}
-
-func validatePassword(password string) error {
- if len(password) < 8 {
- return errors.New("Password should be longer than 8 characters")
- }
-
- return nil
-}
-
-func getClientType(r *http.Request) string {
- origin := r.Header.Get("Origin")
-
- if strings.HasPrefix(origin, "moz-extension://") {
- return "firefox-extension"
- }
-
- if strings.HasPrefix(origin, "chrome-extension://") {
- return "chrome-extension"
- }
-
- userAgent := r.Header.Get("User-Agent")
- if strings.HasPrefix(userAgent, "Go-http-client") {
- return "cli"
- }
-
- return "web"
-}
-
-// notSupported is the handler for the route that is no longer supported
-func (a *API) notSupported(w http.ResponseWriter, r *http.Request) {
- http.Error(w, "API version is not supported. Please upgrade your client.", http.StatusGone)
- return
-}
diff --git a/pkg/server/api/notes.go b/pkg/server/api/notes.go
deleted file mode 100644
index d87c2ff7..00000000
--- a/pkg/server/api/notes.go
+++ /dev/null
@@ -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 .
- */
-
-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=",
- "StopSel=",
- "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(¬es).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(¬es).Error; err != nil {
- handlers.DoError(w, "finding notes", err, http.StatusInternalServerError)
- return
- }
-
- presented := presenters.PresentNotes(notes)
- handlers.RespondJSON(w, http.StatusOK, presented)
-}
diff --git a/pkg/server/api/notes_test.go b/pkg/server/api/notes_test.go
deleted file mode 100644
index c47caf4e..00000000
--- a/pkg/server/api/notes_test.go
+++ /dev/null
@@ -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 .
- */
-
-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")
- })
-}
diff --git a/pkg/server/api/routes.go b/pkg/server/api/routes.go
deleted file mode 100644
index d1b771bf..00000000
--- a/pkg/server/api/routes.go
+++ /dev/null
@@ -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 .
- */
-
-package api
-
-import (
- "net/http"
- "os"
-
- "github.com/dnote/dnote/pkg/server/app"
- "github.com/dnote/dnote/pkg/server/database"
- "github.com/dnote/dnote/pkg/server/handlers"
- "github.com/gorilla/mux"
- "github.com/pkg/errors"
-)
-
-// API is a web API configuration
-type API struct {
- App *app.App
-}
-
-// init sets up the application based on the configuration
-func (a *API) init() error {
- if err := a.App.Validate(); err != nil {
- return errors.Wrap(err, "validating the app parameters")
- }
-
- return nil
-}
-
-func applyMiddleware(h http.HandlerFunc, rateLimit bool) http.Handler {
- ret := h
- ret = handlers.Logging(ret)
-
- if rateLimit && os.Getenv("GO_ENV") != "TEST" {
- ret = handlers.Limit(ret)
- }
-
- return ret
-}
-
-// NewRouter creates and returns a new router
-func NewRouter(a *API) (*mux.Router, error) {
- if err := a.init(); err != nil {
- return nil, errors.Wrap(err, "initializing app")
- }
-
- proOnly := handlers.AuthParams{ProOnly: true}
- app := a.App
-
- var routes = []handlers.Route{
- // internal
- {Method: "GET", Pattern: "/health", HandlerFunc: a.checkHealth, RateLimit: false},
- {Method: "GET", Pattern: "/me", HandlerFunc: handlers.Auth(app, a.getMe, nil), RateLimit: true},
- {Method: "POST", Pattern: "/verification-token", HandlerFunc: handlers.Auth(app, a.createVerificationToken, nil), RateLimit: true},
- {Method: "PATCH", Pattern: "/verify-email", HandlerFunc: a.verifyEmail, RateLimit: true},
- {Method: "POST", Pattern: "/reset-token", HandlerFunc: a.createResetToken, RateLimit: true},
- {Method: "PATCH", Pattern: "/reset-password", HandlerFunc: a.resetPassword, RateLimit: true},
- {Method: "PATCH", Pattern: "/account/profile", HandlerFunc: handlers.Auth(app, a.updateProfile, nil), RateLimit: true},
- {Method: "PATCH", Pattern: "/account/password", HandlerFunc: handlers.Auth(app, a.updatePassword, nil), RateLimit: true},
- {Method: "GET", Pattern: "/account/email-preference", HandlerFunc: handlers.TokenAuth(app, a.getEmailPreference, database.TokenTypeEmailPreference, nil), RateLimit: true},
- {Method: "PATCH", Pattern: "/account/email-preference", HandlerFunc: handlers.TokenAuth(app, a.updateEmailPreference, database.TokenTypeEmailPreference, nil), RateLimit: true},
- {Method: "GET", Pattern: "/notes", HandlerFunc: handlers.Auth(app, a.getNotes, nil), RateLimit: false},
- {Method: "GET", Pattern: "/notes/{noteUUID}", HandlerFunc: a.getNote, RateLimit: true},
- {Method: "GET", Pattern: "/calendar", HandlerFunc: handlers.Auth(app, a.getCalendar, nil), RateLimit: true},
-
- // v3
- {Method: "GET", Pattern: "/v3/sync/fragment", HandlerFunc: handlers.Cors(handlers.Auth(app, a.GetSyncFragment, &proOnly)), RateLimit: false},
- {Method: "GET", Pattern: "/v3/sync/state", HandlerFunc: handlers.Cors(handlers.Auth(app, a.GetSyncState, &proOnly)), RateLimit: false},
- {Method: "OPTIONS", Pattern: "/v3/books", HandlerFunc: handlers.Cors(a.BooksOptions), RateLimit: true},
- {Method: "GET", Pattern: "/v3/books", HandlerFunc: handlers.Cors(handlers.Auth(app, a.GetBooks, &proOnly)), RateLimit: true},
- {Method: "GET", Pattern: "/v3/books/{bookUUID}", HandlerFunc: handlers.Cors(handlers.Auth(app, a.GetBook, &proOnly)), RateLimit: true},
- {Method: "POST", Pattern: "/v3/books", HandlerFunc: handlers.Cors(handlers.Auth(app, a.CreateBook, &proOnly)), RateLimit: false},
- {Method: "PATCH", Pattern: "/v3/books/{bookUUID}", HandlerFunc: handlers.Cors(handlers.Auth(app, a.UpdateBook, &proOnly)), RateLimit: false},
- {Method: "DELETE", Pattern: "/v3/books/{bookUUID}", HandlerFunc: handlers.Cors(handlers.Auth(app, a.DeleteBook, &proOnly)), RateLimit: false},
- {Method: "OPTIONS", Pattern: "/v3/notes", HandlerFunc: handlers.Cors(a.NotesOptions), RateLimit: true},
- {Method: "POST", Pattern: "/v3/notes", HandlerFunc: handlers.Cors(handlers.Auth(app, a.CreateNote, &proOnly)), RateLimit: false},
- {Method: "PATCH", Pattern: "/v3/notes/{noteUUID}", HandlerFunc: handlers.Auth(app, a.UpdateNote, &proOnly), RateLimit: false},
- {Method: "DELETE", Pattern: "/v3/notes/{noteUUID}", HandlerFunc: handlers.Auth(app, a.DeleteNote, &proOnly), RateLimit: false},
- {Method: "POST", Pattern: "/v3/signin", HandlerFunc: handlers.Cors(a.signin), RateLimit: true},
- {Method: "OPTIONS", Pattern: "/v3/signout", HandlerFunc: handlers.Cors(a.signoutOptions), RateLimit: true},
- {Method: "POST", Pattern: "/v3/signout", HandlerFunc: handlers.Cors(a.signout), RateLimit: true},
- {Method: "POST", Pattern: "/v3/register", HandlerFunc: a.register, RateLimit: true},
- }
-
- router := mux.NewRouter().StrictSlash(true)
-
- router.PathPrefix("/v1").Handler(applyMiddleware(handlers.NotSupported, true))
- router.PathPrefix("/v2").Handler(applyMiddleware(handlers.NotSupported, true))
-
- for _, route := range routes {
- handler := route.HandlerFunc
-
- router.
- Methods(route.Method).
- Path(route.Pattern).
- Handler(applyMiddleware(handler, route.RateLimit))
- }
-
- return router, nil
-}
diff --git a/pkg/server/api/routes_test.go b/pkg/server/api/routes_test.go
deleted file mode 100644
index 7b8f5ec2..00000000
--- a/pkg/server/api/routes_test.go
+++ /dev/null
@@ -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 .
- */
-
-package api
-
-import (
- "fmt"
- "net/http"
- "testing"
-
- "github.com/dnote/dnote/pkg/assert"
- "github.com/dnote/dnote/pkg/clock"
- "github.com/dnote/dnote/pkg/server/app"
- "github.com/dnote/dnote/pkg/server/config"
- "github.com/dnote/dnote/pkg/server/mailer"
- "github.com/dnote/dnote/pkg/server/testutils"
- "github.com/jinzhu/gorm"
- "github.com/pkg/errors"
-)
-
-func TestNotSupportedVersions(t *testing.T) {
- testCases := []struct {
- path string
- }{
- // v1
- {
- path: "/v1",
- },
- {
- path: "/v1/foo",
- },
- {
- path: "/v1/bar/baz",
- },
- // v2
- {
- path: "/v2",
- },
- {
- path: "/v2/foo",
- },
- {
- path: "/v2/bar/baz",
- },
- }
-
- // setup
- server := MustNewServer(t, &app.App{
- DB: &gorm.DB{},
- Clock: clock.NewMock(),
- })
- defer server.Close()
-
- for _, tc := range testCases {
- t.Run(tc.path, func(t *testing.T) {
- // execute
- req := testutils.MakeReq(server.URL, "GET", tc.path, "")
- res := testutils.HTTPDo(t, req)
-
- // test
- assert.Equal(t, res.StatusCode, http.StatusGone, "status code mismatch")
- })
- }
-}
-
-func TestNewRouter_AppValidate(t *testing.T) {
- c := config.Load()
-
- configWithoutWebURL := config.Load()
- configWithoutWebURL.WebURL = ""
-
- testCases := []struct {
- app app.App
- expectedErr error
- }{
- {
- app: app.App{
- DB: &gorm.DB{},
- Clock: clock.NewMock(),
- EmailTemplates: mailer.Templates{},
- EmailBackend: &testutils.MockEmailbackendImplementation{},
- Config: c,
- },
- expectedErr: nil,
- },
- {
- app: app.App{
- DB: nil,
- Clock: clock.NewMock(),
- EmailTemplates: mailer.Templates{},
- EmailBackend: &testutils.MockEmailbackendImplementation{},
- Config: c,
- },
- expectedErr: app.ErrEmptyDB,
- },
- {
- app: app.App{
- DB: &gorm.DB{},
- Clock: nil,
- EmailTemplates: mailer.Templates{},
- EmailBackend: &testutils.MockEmailbackendImplementation{},
- Config: c,
- },
- expectedErr: app.ErrEmptyClock,
- },
- {
- app: app.App{
- DB: &gorm.DB{},
- Clock: clock.NewMock(),
- EmailTemplates: nil,
- EmailBackend: &testutils.MockEmailbackendImplementation{},
- Config: c,
- },
- expectedErr: app.ErrEmptyEmailTemplates,
- },
- {
- app: app.App{
- DB: &gorm.DB{},
- Clock: clock.NewMock(),
- EmailTemplates: mailer.Templates{},
- EmailBackend: nil,
- Config: c,
- },
- expectedErr: app.ErrEmptyEmailBackend,
- },
- {
- app: app.App{
- DB: &gorm.DB{},
- Clock: clock.NewMock(),
- EmailTemplates: mailer.Templates{},
- EmailBackend: &testutils.MockEmailbackendImplementation{},
- Config: configWithoutWebURL,
- },
- expectedErr: app.ErrEmptyWebURL,
- },
- }
-
- for idx, tc := range testCases {
- t.Run(fmt.Sprintf("test case %d", idx), func(t *testing.T) {
- api := API{App: &tc.app}
- _, err := NewRouter(&api)
-
- assert.Equal(t, errors.Cause(err), tc.expectedErr, "error mismatch")
- })
- }
-}
diff --git a/pkg/server/api/user.go b/pkg/server/api/user.go
deleted file mode 100644
index d4a8ec51..00000000
--- a/pkg/server/api/user.go
+++ /dev/null
@@ -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 .
- */
-
-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(¶ms)
- 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(¶ms); 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(¶ms); 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(¶ms); 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)
-}
diff --git a/pkg/server/api/user_test.go b/pkg/server/api/user_test.go
deleted file mode 100644
index 95df0179..00000000
--- a/pkg/server/api/user_test.go
+++ /dev/null
@@ -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 .
- */
-
-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")
-}
diff --git a/pkg/server/api/v3_auth.go b/pkg/server/api/v3_auth.go
deleted file mode 100644
index 580f82e1..00000000
--- a/pkg/server/api/v3_auth.go
+++ /dev/null
@@ -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 .
- */
-
-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(¶ms)
- 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
- }
-}
diff --git a/pkg/server/api/v3_auth_test.go b/pkg/server/api/v3_auth_test.go
deleted file mode 100644
index 2aa4761e..00000000
--- a/pkg/server/api/v3_auth_test.go
+++ /dev/null
@@ -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 .
- */
-
-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")
- })
-}
diff --git a/pkg/server/api/v3_books.go b/pkg/server/api/v3_books.go
deleted file mode 100644
index 34985bb5..00000000
--- a/pkg/server/api/v3_books.go
+++ /dev/null
@@ -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 .
- */
-
-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(¶ms)
- 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(¶ms)
- 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(¬es).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)
-}
diff --git a/pkg/server/api/v3_notes.go b/pkg/server/api/v3_notes.go
deleted file mode 100644
index c38ced5a..00000000
--- a/pkg/server/api/v3_notes.go
+++ /dev/null
@@ -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 .
- */
-
-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(¶ms)
- 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(¬e).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(¬e).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(¶ms)
- 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")
-}
diff --git a/pkg/server/api/v3_notes_test.go b/pkg/server/api/v3_notes_test.go
deleted file mode 100644
index df30beed..00000000
--- a/pkg/server/api/v3_notes_test.go
+++ /dev/null
@@ -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 .
- */
-
-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(¬eCount), "counting notes")
- testutils.MustExec(t, testutils.DB.First(¬eRecord), "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(¬e), "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(¬eCount), "counting notes")
- testutils.MustExec(t, testutils.DB.Where("uuid = ?", note.UUID).First(¬eRecord), "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(¬e), "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(¬eCount), "counting notes")
- testutils.MustExec(t, testutils.DB.Where("uuid = ?", note.UUID).First(¬eRecord), "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")
- })
- }
-}
diff --git a/pkg/server/app/app.go b/pkg/server/app/app.go
index c530d4fa..06e50010 100644
--- a/pkg/server/app/app.go
+++ b/pkg/server/app/app.go
@@ -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
diff --git a/pkg/server/app/email.go b/pkg/server/app/email.go
index 64485340..5a377902 100644
--- a/pkg/server/app/email.go
+++ b/pkg/server/app/email.go
@@ -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)
}
diff --git a/pkg/server/app/errors.go b/pkg/server/app/errors.go
new file mode 100644
index 00000000..d96b9fb2
--- /dev/null
+++ b/pkg/server/app/errors.go
@@ -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."
+)
diff --git a/pkg/server/app/notes.go b/pkg/server/app/notes.go
index 054a4989..83a24b85 100644
--- a/pkg/server/app/notes.go
+++ b/pkg/server/app/notes.go
@@ -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=",
+ "StopSel=",
+ "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(¬es).Error; err != nil {
+ return GetNotesResult{}, errors.Wrap(err, "finding notes")
+ }
+ }
+
+ res := GetNotesResult{
+ Notes: notes,
+ Total: total,
+ }
+
+ return res, nil
+}
diff --git a/pkg/server/app/testutils.go b/pkg/server/app/testutils.go
index 6645e430..41091625 100644
--- a/pkg/server/app/testutils.go
+++ b/pkg/server/app/testutils.go
@@ -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
}
diff --git a/pkg/server/app/users.go b/pkg/server/app/users.go
index 9261dcb9..4c3a335f 100644
--- a/pkg/server/app/users.go
+++ b/pkg/server/app/users.go
@@ -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
+}
diff --git a/pkg/server/app/users_test.go b/pkg/server/app/users_test.go
index b78458f6..37ea4d6d 100644
--- a/pkg/server/app/users_test.go
+++ b/pkg/server/app/users_test.go
@@ -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")
+ })
+}
diff --git a/pkg/server/assets/js/build.sh b/pkg/server/assets/js/build.sh
new file mode 100755
index 00000000..4c9cc599
--- /dev/null
+++ b/pkg/server/assets/js/build.sh
@@ -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
diff --git a/pkg/server/assets/js/src/main.js b/pkg/server/assets/js/src/main.js
new file mode 100644
index 00000000..7d32f867
--- /dev/null
+++ b/pkg/server/assets/js/src/main.js
@@ -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');
+ }
+ }
+ }
+};
diff --git a/pkg/server/assets/package-lock.json b/pkg/server/assets/package-lock.json
new file mode 100644
index 00000000..101aff52
--- /dev/null
+++ b/pkg/server/assets/package-lock.json
@@ -0,0 +1,157 @@
+{
+ "name": "assets",
+ "version": "1.0.0",
+ "lockfileVersion": 1,
+ "requires": true,
+ "dependencies": {
+ "anymatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz",
+ "integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==",
+ "dev": true,
+ "requires": {
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
+ }
+ },
+ "binary-extensions": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
+ "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==",
+ "dev": true
+ },
+ "braces": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
+ "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
+ "dev": true,
+ "requires": {
+ "fill-range": "^7.0.1"
+ }
+ },
+ "chokidar": {
+ "version": "3.5.3",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
+ "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==",
+ "dev": true,
+ "requires": {
+ "anymatch": "~3.1.2",
+ "braces": "~3.0.2",
+ "fsevents": "~2.3.2",
+ "glob-parent": "~5.1.2",
+ "is-binary-path": "~2.1.0",
+ "is-glob": "~4.0.1",
+ "normalize-path": "~3.0.0",
+ "readdirp": "~3.6.0"
+ }
+ },
+ "fill-range": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
+ "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
+ "dev": true,
+ "requires": {
+ "to-regex-range": "^5.0.1"
+ }
+ },
+ "fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "optional": true
+ },
+ "glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "requires": {
+ "is-glob": "^4.0.1"
+ }
+ },
+ "immutable": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.0.0.tgz",
+ "integrity": "sha512-zIE9hX70qew5qTUjSS7wi1iwj/l7+m54KWU247nhM3v806UdGj1yDndXj+IOYxxtW9zyLI+xqFNZjTuDaLUqFw==",
+ "dev": true
+ },
+ "is-binary-path": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+ "dev": true,
+ "requires": {
+ "binary-extensions": "^2.0.0"
+ }
+ },
+ "is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=",
+ "dev": true
+ },
+ "is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "requires": {
+ "is-extglob": "^2.1.1"
+ }
+ },
+ "is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true
+ },
+ "normalize-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+ "dev": true
+ },
+ "picomatch": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "dev": true
+ },
+ "readdirp": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+ "dev": true,
+ "requires": {
+ "picomatch": "^2.2.1"
+ }
+ },
+ "sass": {
+ "version": "1.50.1",
+ "resolved": "https://registry.npmjs.org/sass/-/sass-1.50.1.tgz",
+ "integrity": "sha512-noTnY41KnlW2A9P8sdwESpDmo+KBNkukI1i8+hOK3footBUcohNHtdOJbckp46XO95nuvcHDDZ+4tmOnpK3hjw==",
+ "dev": true,
+ "requires": {
+ "chokidar": ">=3.0.0 <4.0.0",
+ "immutable": "^4.0.0",
+ "source-map-js": ">=0.6.2 <2.0.0"
+ }
+ },
+ "source-map-js": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz",
+ "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==",
+ "dev": true
+ },
+ "to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "requires": {
+ "is-number": "^7.0.0"
+ }
+ }
+ }
+}
diff --git a/pkg/server/assets/package.json b/pkg/server/assets/package.json
new file mode 100644
index 00000000..0edcd180
--- /dev/null
+++ b/pkg/server/assets/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "assets",
+ "version": "1.0.0",
+ "description": "assets",
+ "main": "index.js",
+ "scripts": {},
+ "author": "Dnote",
+ "license": "AGPL-3.0-or-later",
+ "devDependencies": {
+ "sass": "^1.50.1"
+ }
+}
diff --git a/pkg/server/assets/static/500.html b/pkg/server/assets/static/500.html
new file mode 100644
index 00000000..ccf85d20
--- /dev/null
+++ b/pkg/server/assets/static/500.html
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+ 500
+
+
diff --git a/pkg/server/assets/static/android-icon-144x144.png b/pkg/server/assets/static/android-icon-144x144.png
new file mode 100644
index 00000000..09b28d2f
Binary files /dev/null and b/pkg/server/assets/static/android-icon-144x144.png differ
diff --git a/pkg/server/assets/static/android-icon-192x192.png b/pkg/server/assets/static/android-icon-192x192.png
new file mode 100644
index 00000000..48195236
Binary files /dev/null and b/pkg/server/assets/static/android-icon-192x192.png differ
diff --git a/pkg/server/assets/static/android-icon-36x36.png b/pkg/server/assets/static/android-icon-36x36.png
new file mode 100644
index 00000000..7c9f770f
Binary files /dev/null and b/pkg/server/assets/static/android-icon-36x36.png differ
diff --git a/pkg/server/assets/static/android-icon-48x48.png b/pkg/server/assets/static/android-icon-48x48.png
new file mode 100644
index 00000000..d93d8cda
Binary files /dev/null and b/pkg/server/assets/static/android-icon-48x48.png differ
diff --git a/pkg/server/assets/static/android-icon-72x72.png b/pkg/server/assets/static/android-icon-72x72.png
new file mode 100644
index 00000000..aa2e9876
Binary files /dev/null and b/pkg/server/assets/static/android-icon-72x72.png differ
diff --git a/pkg/server/assets/static/android-icon-96x96.png b/pkg/server/assets/static/android-icon-96x96.png
new file mode 100644
index 00000000..6d711b98
Binary files /dev/null and b/pkg/server/assets/static/android-icon-96x96.png differ
diff --git a/pkg/server/assets/static/apple-icon-114x114.png b/pkg/server/assets/static/apple-icon-114x114.png
new file mode 100644
index 00000000..e6b7fece
Binary files /dev/null and b/pkg/server/assets/static/apple-icon-114x114.png differ
diff --git a/pkg/server/assets/static/apple-icon-120x120.png b/pkg/server/assets/static/apple-icon-120x120.png
new file mode 100644
index 00000000..871eb9d0
Binary files /dev/null and b/pkg/server/assets/static/apple-icon-120x120.png differ
diff --git a/pkg/server/assets/static/apple-icon-144x144.png b/pkg/server/assets/static/apple-icon-144x144.png
new file mode 100644
index 00000000..09b28d2f
Binary files /dev/null and b/pkg/server/assets/static/apple-icon-144x144.png differ
diff --git a/pkg/server/assets/static/apple-icon-152x152.png b/pkg/server/assets/static/apple-icon-152x152.png
new file mode 100644
index 00000000..16a2dd70
Binary files /dev/null and b/pkg/server/assets/static/apple-icon-152x152.png differ
diff --git a/pkg/server/assets/static/apple-icon-180x180.png b/pkg/server/assets/static/apple-icon-180x180.png
new file mode 100644
index 00000000..77d31532
Binary files /dev/null and b/pkg/server/assets/static/apple-icon-180x180.png differ
diff --git a/pkg/server/assets/static/apple-icon-57x57.png b/pkg/server/assets/static/apple-icon-57x57.png
new file mode 100644
index 00000000..ca17d420
Binary files /dev/null and b/pkg/server/assets/static/apple-icon-57x57.png differ
diff --git a/pkg/server/assets/static/apple-icon-60x60.png b/pkg/server/assets/static/apple-icon-60x60.png
new file mode 100644
index 00000000..9dbf917e
Binary files /dev/null and b/pkg/server/assets/static/apple-icon-60x60.png differ
diff --git a/pkg/server/assets/static/apple-icon-72x72.png b/pkg/server/assets/static/apple-icon-72x72.png
new file mode 100644
index 00000000..aa2e9876
Binary files /dev/null and b/pkg/server/assets/static/apple-icon-72x72.png differ
diff --git a/pkg/server/assets/static/apple-icon-76x76.png b/pkg/server/assets/static/apple-icon-76x76.png
new file mode 100644
index 00000000..5e4af4bc
Binary files /dev/null and b/pkg/server/assets/static/apple-icon-76x76.png differ
diff --git a/pkg/server/assets/static/apple-icon-precomposed.png b/pkg/server/assets/static/apple-icon-precomposed.png
new file mode 100644
index 00000000..f3410b4e
Binary files /dev/null and b/pkg/server/assets/static/apple-icon-precomposed.png differ
diff --git a/pkg/server/assets/static/apple-icon.png b/pkg/server/assets/static/apple-icon.png
new file mode 100644
index 00000000..f3410b4e
Binary files /dev/null and b/pkg/server/assets/static/apple-icon.png differ
diff --git a/pkg/server/assets/static/browserconfig.xml b/pkg/server/assets/static/browserconfig.xml
new file mode 100644
index 00000000..c5541482
--- /dev/null
+++ b/pkg/server/assets/static/browserconfig.xml
@@ -0,0 +1,2 @@
+
+#ffffff
\ No newline at end of file
diff --git a/pkg/server/assets/static/favicon-16x16.png b/pkg/server/assets/static/favicon-16x16.png
new file mode 100644
index 00000000..798fbd5c
Binary files /dev/null and b/pkg/server/assets/static/favicon-16x16.png differ
diff --git a/pkg/server/assets/static/favicon-32x32.png b/pkg/server/assets/static/favicon-32x32.png
new file mode 100644
index 00000000..a1be50c4
Binary files /dev/null and b/pkg/server/assets/static/favicon-32x32.png differ
diff --git a/pkg/server/assets/static/favicon-96x96.png b/pkg/server/assets/static/favicon-96x96.png
new file mode 100644
index 00000000..6d711b98
Binary files /dev/null and b/pkg/server/assets/static/favicon-96x96.png differ
diff --git a/pkg/server/assets/static/favicon.ico b/pkg/server/assets/static/favicon.ico
new file mode 100644
index 00000000..c71572aa
Binary files /dev/null and b/pkg/server/assets/static/favicon.ico differ
diff --git a/pkg/server/assets/static/logo-512x512.png b/pkg/server/assets/static/logo-512x512.png
new file mode 100644
index 00000000..141992ac
Binary files /dev/null and b/pkg/server/assets/static/logo-512x512.png differ
diff --git a/pkg/server/assets/static/manifest.json b/pkg/server/assets/static/manifest.json
new file mode 100644
index 00000000..874b25a6
--- /dev/null
+++ b/pkg/server/assets/static/manifest.json
@@ -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"
+}
diff --git a/pkg/server/assets/static/ms-icon-144x144.png b/pkg/server/assets/static/ms-icon-144x144.png
new file mode 100644
index 00000000..09b28d2f
Binary files /dev/null and b/pkg/server/assets/static/ms-icon-144x144.png differ
diff --git a/pkg/server/assets/static/ms-icon-150x150.png b/pkg/server/assets/static/ms-icon-150x150.png
new file mode 100644
index 00000000..0fa43061
Binary files /dev/null and b/pkg/server/assets/static/ms-icon-150x150.png differ
diff --git a/pkg/server/assets/static/ms-icon-310x310.png b/pkg/server/assets/static/ms-icon-310x310.png
new file mode 100644
index 00000000..c8974c78
Binary files /dev/null and b/pkg/server/assets/static/ms-icon-310x310.png differ
diff --git a/pkg/server/assets/static/ms-icon-70x70.png b/pkg/server/assets/static/ms-icon-70x70.png
new file mode 100644
index 00000000..78fdbd5a
Binary files /dev/null and b/pkg/server/assets/static/ms-icon-70x70.png differ
diff --git a/pkg/server/assets/static/offline.html b/pkg/server/assets/static/offline.html
new file mode 100644
index 00000000..0aadab72
--- /dev/null
+++ b/pkg/server/assets/static/offline.html
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+ Page Not Found | Dnote
+
+
+
+
+ You are offline
+
+ Please check you connection and try again.
+
+
+
+
diff --git a/pkg/server/assets/styles/build.sh b/pkg/server/assets/styles/build.sh
new file mode 100755
index 00000000..cde19626
--- /dev/null
+++ b/pkg/server/assets/styles/build.sh
@@ -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:?}/*"
+
+"$dir/../node_modules/.bin/sass" --version
+
+task="$dir/../node_modules/.bin/sass \
+ --style compressed \
+ --source-map \
+ $inputDir:$outputDir"
+
+# compile first then watch
+eval "$task"
+
+if [[ "$1" == "true" ]]; then
+ eval "$task --watch --poll"
+fi
diff --git a/pkg/server/assets/styles/src/_books.scss b/pkg/server/assets/styles/src/_books.scss
new file mode 100644
index 00000000..9dcc5a1a
--- /dev/null
+++ b/pkg/server/assets/styles/src/_books.scss
@@ -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);
+ }
+ }
+}
diff --git a/pkg/server/assets/styles/src/_bootstrap.scss b/pkg/server/assets/styles/src/_bootstrap.scss
new file mode 100644
index 00000000..ea4a69de
--- /dev/null
+++ b/pkg/server/assets/styles/src/_bootstrap.scss
@@ -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 .
+ */
+
+// 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;
+}
diff --git a/pkg/server/assets/styles/src/_buttons.scss b/pkg/server/assets/styles/src/_buttons.scss
new file mode 100644
index 00000000..6178c0ce
--- /dev/null
+++ b/pkg/server/assets/styles/src/_buttons.scss
@@ -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 .
+ */
+
+@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;
+ }
+}
diff --git a/pkg/server/assets/styles/src/_font.scss b/pkg/server/assets/styles/src/_font.scss
new file mode 100644
index 00000000..1f0b90d5
--- /dev/null
+++ b/pkg/server/assets/styles/src/_font.scss
@@ -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 .
+ */
+
+@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;
+ }
+}
diff --git a/pkg/server/assets/styles/src/_global.scss b/pkg/server/assets/styles/src/_global.scss
new file mode 100644
index 00000000..d7650265
--- /dev/null
+++ b/pkg/server/assets/styles/src/_global.scss
@@ -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;
+ }
+ }
+}
diff --git a/pkg/server/assets/styles/src/_grid.scss b/pkg/server/assets/styles/src/_grid.scss
new file mode 100644
index 00000000..66e07a28
--- /dev/null
+++ b/pkg/server/assets/styles/src/_grid.scss
@@ -0,0 +1,1108 @@
+/* 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 .
+ */
+
+/*!
+ * Bootstrap Grid 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)
+ */
+html {
+ box-sizing: border-box;
+ -ms-overflow-style: scrollbar;
+}
+
+*,
+*::before,
+*::after {
+ box-sizing: inherit;
+}
+
+.container-wide {
+ width: 100%;
+ padding-right: 15px;
+ padding-left: 15px;
+ margin-right: auto;
+ margin-left: auto;
+}
+
+@media (min-width: 576px) {
+ .container-wide {
+ max-width: 540px;
+ }
+}
+
+@media (min-width: 768px) {
+ .container-wide {
+ max-width: 720px;
+ }
+}
+
+@media (min-width: 992px) {
+ .container-wide {
+ max-width: 960px;
+ }
+}
+
+@media (min-width: 1200px) {
+ .container-wide {
+ max-width: 1040px;
+ }
+}
+
+@media (min-width: 1440px) {
+ .container-wide {
+ max-width: 1280px;
+ }
+}
+
+@media (min-width: 1800px) {
+ .container-wide {
+ max-width: 1660px;
+ }
+}
+
+.container-fluid {
+ width: 100%;
+ padding-right: 15px;
+ padding-left: 15px;
+ margin-right: auto;
+ margin-left: auto;
+}
+
+.container {
+ width: 100%;
+ padding-right: 15px;
+ padding-left: 15px;
+ margin-right: auto;
+ margin-left: auto;
+}
+
+@media (min-width: 576px) {
+ .container {
+ max-width: 540px;
+ }
+}
+
+@media (min-width: 768px) {
+ .container {
+ max-width: 720px;
+ }
+}
+
+@media (min-width: 992px) {
+ .container {
+ max-width: 960px;
+ }
+}
+
+@media (min-width: 1200px) {
+ .container {
+ max-width: 1280px;
+ }
+}
+
+.row {
+ display: -ms-flexbox;
+ display: flex;
+ -ms-flex-wrap: wrap;
+ flex-wrap: wrap;
+ margin-right: -15px;
+ margin-left: -15px;
+}
+
+.no-gutters {
+ margin-right: 0;
+ margin-left: 0;
+}
+
+.no-gutters > .col,
+.no-gutters > [class*='col-'] {
+ padding-right: 0;
+ padding-left: 0;
+}
+
+.col-1,
+.col-2,
+.col-3,
+.col-4,
+.col-5,
+.col-6,
+.col-7,
+.col-8,
+.col-9,
+.col-10,
+.col-11,
+.col-12,
+.col,
+.col-auto,
+.col-sm-1,
+.col-sm-2,
+.col-sm-3,
+.col-sm-4,
+.col-sm-5,
+.col-sm-6,
+.col-sm-7,
+.col-sm-8,
+.col-sm-9,
+.col-sm-10,
+.col-sm-11,
+.col-sm-12,
+.col-sm,
+.col-sm-auto,
+.col-md-1,
+.col-md-2,
+.col-md-3,
+.col-md-4,
+.col-md-5,
+.col-md-6,
+.col-md-7,
+.col-md-8,
+.col-md-9,
+.col-md-10,
+.col-md-11,
+.col-md-12,
+.col-md,
+.col-md-auto,
+.col-lg-1,
+.col-lg-2,
+.col-lg-3,
+.col-lg-4,
+.col-lg-5,
+.col-lg-6,
+.col-lg-7,
+.col-lg-8,
+.col-lg-9,
+.col-lg-10,
+.col-lg-11,
+.col-lg-12,
+.col-lg,
+.col-lg-auto,
+.col-xl-1,
+.col-xl-2,
+.col-xl-3,
+.col-xl-4,
+.col-xl-5,
+.col-xl-6,
+.col-xl-7,
+.col-xl-8,
+.col-xl-9,
+.col-xl-10,
+.col-xl-11,
+.col-xl-12,
+.col-xl,
+.col-xl-auto {
+ position: relative;
+ width: 100%;
+ padding-right: 15px;
+ padding-left: 15px;
+}
+
+.col {
+ -ms-flex-preferred-size: 0;
+ flex-basis: 0;
+ -ms-flex-positive: 1;
+ flex-grow: 1;
+ max-width: 100%;
+}
+
+.col-auto {
+ -ms-flex: 0 0 auto;
+ flex: 0 0 auto;
+ width: auto;
+ max-width: 100%;
+}
+
+.col-1 {
+ -ms-flex: 0 0 8.333333%;
+ flex: 0 0 8.333333%;
+ max-width: 8.333333%;
+}
+
+.col-2 {
+ -ms-flex: 0 0 16.666667%;
+ flex: 0 0 16.666667%;
+ max-width: 16.666667%;
+}
+
+.col-3 {
+ -ms-flex: 0 0 25%;
+ flex: 0 0 25%;
+ max-width: 25%;
+}
+
+.col-4 {
+ -ms-flex: 0 0 33.333333%;
+ flex: 0 0 33.333333%;
+ max-width: 33.333333%;
+}
+
+.col-5 {
+ -ms-flex: 0 0 41.666667%;
+ flex: 0 0 41.666667%;
+ max-width: 41.666667%;
+}
+
+.col-6 {
+ -ms-flex: 0 0 50%;
+ flex: 0 0 50%;
+ max-width: 50%;
+}
+
+.col-7 {
+ -ms-flex: 0 0 58.333333%;
+ flex: 0 0 58.333333%;
+ max-width: 58.333333%;
+}
+
+.col-8 {
+ -ms-flex: 0 0 66.666667%;
+ flex: 0 0 66.666667%;
+ max-width: 66.666667%;
+}
+
+.col-9 {
+ -ms-flex: 0 0 75%;
+ flex: 0 0 75%;
+ max-width: 75%;
+}
+
+.col-10 {
+ -ms-flex: 0 0 83.333333%;
+ flex: 0 0 83.333333%;
+ max-width: 83.333333%;
+}
+
+.col-11 {
+ -ms-flex: 0 0 91.666667%;
+ flex: 0 0 91.666667%;
+ max-width: 91.666667%;
+}
+
+.col-12 {
+ -ms-flex: 0 0 100%;
+ flex: 0 0 100%;
+ max-width: 100%;
+}
+
+.order-first {
+ -ms-flex-order: -1;
+ order: -1;
+}
+
+.order-last {
+ -ms-flex-order: 13;
+ order: 13;
+}
+
+.order-0 {
+ -ms-flex-order: 0;
+ order: 0;
+}
+
+.order-1 {
+ -ms-flex-order: 1;
+ order: 1;
+}
+
+.order-2 {
+ -ms-flex-order: 2;
+ order: 2;
+}
+
+.order-3 {
+ -ms-flex-order: 3;
+ order: 3;
+}
+
+.order-4 {
+ -ms-flex-order: 4;
+ order: 4;
+}
+
+.order-5 {
+ -ms-flex-order: 5;
+ order: 5;
+}
+
+.order-6 {
+ -ms-flex-order: 6;
+ order: 6;
+}
+
+.order-7 {
+ -ms-flex-order: 7;
+ order: 7;
+}
+
+.order-8 {
+ -ms-flex-order: 8;
+ order: 8;
+}
+
+.order-9 {
+ -ms-flex-order: 9;
+ order: 9;
+}
+
+.order-10 {
+ -ms-flex-order: 10;
+ order: 10;
+}
+
+.order-11 {
+ -ms-flex-order: 11;
+ order: 11;
+}
+
+.order-12 {
+ -ms-flex-order: 12;
+ order: 12;
+}
+
+.offset-1 {
+ margin-left: 8.333333%;
+}
+
+.offset-2 {
+ margin-left: 16.666667%;
+}
+
+.offset-3 {
+ margin-left: 25%;
+}
+
+.offset-4 {
+ margin-left: 33.333333%;
+}
+
+.offset-5 {
+ margin-left: 41.666667%;
+}
+
+.offset-6 {
+ margin-left: 50%;
+}
+
+.offset-7 {
+ margin-left: 58.333333%;
+}
+
+.offset-8 {
+ margin-left: 66.666667%;
+}
+
+.offset-9 {
+ margin-left: 75%;
+}
+
+.offset-10 {
+ margin-left: 83.333333%;
+}
+
+.offset-11 {
+ margin-left: 91.666667%;
+}
+
+@media (min-width: 576px) {
+ .col-sm {
+ -ms-flex-preferred-size: 0;
+ flex-basis: 0;
+ -ms-flex-positive: 1;
+ flex-grow: 1;
+ max-width: 100%;
+ }
+ .col-sm-auto {
+ -ms-flex: 0 0 auto;
+ flex: 0 0 auto;
+ width: auto;
+ max-width: 100%;
+ }
+ .col-sm-1 {
+ -ms-flex: 0 0 8.333333%;
+ flex: 0 0 8.333333%;
+ max-width: 8.333333%;
+ }
+ .col-sm-2 {
+ -ms-flex: 0 0 16.666667%;
+ flex: 0 0 16.666667%;
+ max-width: 16.666667%;
+ }
+ .col-sm-3 {
+ -ms-flex: 0 0 25%;
+ flex: 0 0 25%;
+ max-width: 25%;
+ }
+ .col-sm-4 {
+ -ms-flex: 0 0 33.333333%;
+ flex: 0 0 33.333333%;
+ max-width: 33.333333%;
+ }
+ .col-sm-5 {
+ -ms-flex: 0 0 41.666667%;
+ flex: 0 0 41.666667%;
+ max-width: 41.666667%;
+ }
+ .col-sm-6 {
+ -ms-flex: 0 0 50%;
+ flex: 0 0 50%;
+ max-width: 50%;
+ }
+ .col-sm-7 {
+ -ms-flex: 0 0 58.333333%;
+ flex: 0 0 58.333333%;
+ max-width: 58.333333%;
+ }
+ .col-sm-8 {
+ -ms-flex: 0 0 66.666667%;
+ flex: 0 0 66.666667%;
+ max-width: 66.666667%;
+ }
+ .col-sm-9 {
+ -ms-flex: 0 0 75%;
+ flex: 0 0 75%;
+ max-width: 75%;
+ }
+ .col-sm-10 {
+ -ms-flex: 0 0 83.333333%;
+ flex: 0 0 83.333333%;
+ max-width: 83.333333%;
+ }
+ .col-sm-11 {
+ -ms-flex: 0 0 91.666667%;
+ flex: 0 0 91.666667%;
+ max-width: 91.666667%;
+ }
+ .col-sm-12 {
+ -ms-flex: 0 0 100%;
+ flex: 0 0 100%;
+ max-width: 100%;
+ }
+ .order-sm-first {
+ -ms-flex-order: -1;
+ order: -1;
+ }
+ .order-sm-last {
+ -ms-flex-order: 13;
+ order: 13;
+ }
+ .order-sm-0 {
+ -ms-flex-order: 0;
+ order: 0;
+ }
+ .order-sm-1 {
+ -ms-flex-order: 1;
+ order: 1;
+ }
+ .order-sm-2 {
+ -ms-flex-order: 2;
+ order: 2;
+ }
+ .order-sm-3 {
+ -ms-flex-order: 3;
+ order: 3;
+ }
+ .order-sm-4 {
+ -ms-flex-order: 4;
+ order: 4;
+ }
+ .order-sm-5 {
+ -ms-flex-order: 5;
+ order: 5;
+ }
+ .order-sm-6 {
+ -ms-flex-order: 6;
+ order: 6;
+ }
+ .order-sm-7 {
+ -ms-flex-order: 7;
+ order: 7;
+ }
+ .order-sm-8 {
+ -ms-flex-order: 8;
+ order: 8;
+ }
+ .order-sm-9 {
+ -ms-flex-order: 9;
+ order: 9;
+ }
+ .order-sm-10 {
+ -ms-flex-order: 10;
+ order: 10;
+ }
+ .order-sm-11 {
+ -ms-flex-order: 11;
+ order: 11;
+ }
+ .order-sm-12 {
+ -ms-flex-order: 12;
+ order: 12;
+ }
+ .offset-sm-0 {
+ margin-left: 0;
+ }
+ .offset-sm-1 {
+ margin-left: 8.333333%;
+ }
+ .offset-sm-2 {
+ margin-left: 16.666667%;
+ }
+ .offset-sm-3 {
+ margin-left: 25%;
+ }
+ .offset-sm-4 {
+ margin-left: 33.333333%;
+ }
+ .offset-sm-5 {
+ margin-left: 41.666667%;
+ }
+ .offset-sm-6 {
+ margin-left: 50%;
+ }
+ .offset-sm-7 {
+ margin-left: 58.333333%;
+ }
+ .offset-sm-8 {
+ margin-left: 66.666667%;
+ }
+ .offset-sm-9 {
+ margin-left: 75%;
+ }
+ .offset-sm-10 {
+ margin-left: 83.333333%;
+ }
+ .offset-sm-11 {
+ margin-left: 91.666667%;
+ }
+}
+
+@media (min-width: 768px) {
+ .col-md {
+ -ms-flex-preferred-size: 0;
+ flex-basis: 0;
+ -ms-flex-positive: 1;
+ flex-grow: 1;
+ max-width: 100%;
+ }
+ .col-md-auto {
+ -ms-flex: 0 0 auto;
+ flex: 0 0 auto;
+ width: auto;
+ max-width: 100%;
+ }
+ .col-md-1 {
+ -ms-flex: 0 0 8.333333%;
+ flex: 0 0 8.333333%;
+ max-width: 8.333333%;
+ }
+ .col-md-2 {
+ -ms-flex: 0 0 16.666667%;
+ flex: 0 0 16.666667%;
+ max-width: 16.666667%;
+ }
+ .col-md-3 {
+ -ms-flex: 0 0 25%;
+ flex: 0 0 25%;
+ max-width: 25%;
+ }
+ .col-md-4 {
+ -ms-flex: 0 0 33.333333%;
+ flex: 0 0 33.333333%;
+ max-width: 33.333333%;
+ }
+ .col-md-5 {
+ -ms-flex: 0 0 41.666667%;
+ flex: 0 0 41.666667%;
+ max-width: 41.666667%;
+ }
+ .col-md-6 {
+ -ms-flex: 0 0 50%;
+ flex: 0 0 50%;
+ max-width: 50%;
+ }
+ .col-md-7 {
+ -ms-flex: 0 0 58.333333%;
+ flex: 0 0 58.333333%;
+ max-width: 58.333333%;
+ }
+ .col-md-8 {
+ -ms-flex: 0 0 66.666667%;
+ flex: 0 0 66.666667%;
+ max-width: 66.666667%;
+ }
+ .col-md-9 {
+ -ms-flex: 0 0 75%;
+ flex: 0 0 75%;
+ max-width: 75%;
+ }
+ .col-md-10 {
+ -ms-flex: 0 0 83.333333%;
+ flex: 0 0 83.333333%;
+ max-width: 83.333333%;
+ }
+ .col-md-11 {
+ -ms-flex: 0 0 91.666667%;
+ flex: 0 0 91.666667%;
+ max-width: 91.666667%;
+ }
+ .col-md-12 {
+ -ms-flex: 0 0 100%;
+ flex: 0 0 100%;
+ max-width: 100%;
+ }
+ .order-md-first {
+ -ms-flex-order: -1;
+ order: -1;
+ }
+ .order-md-last {
+ -ms-flex-order: 13;
+ order: 13;
+ }
+ .order-md-0 {
+ -ms-flex-order: 0;
+ order: 0;
+ }
+ .order-md-1 {
+ -ms-flex-order: 1;
+ order: 1;
+ }
+ .order-md-2 {
+ -ms-flex-order: 2;
+ order: 2;
+ }
+ .order-md-3 {
+ -ms-flex-order: 3;
+ order: 3;
+ }
+ .order-md-4 {
+ -ms-flex-order: 4;
+ order: 4;
+ }
+ .order-md-5 {
+ -ms-flex-order: 5;
+ order: 5;
+ }
+ .order-md-6 {
+ -ms-flex-order: 6;
+ order: 6;
+ }
+ .order-md-7 {
+ -ms-flex-order: 7;
+ order: 7;
+ }
+ .order-md-8 {
+ -ms-flex-order: 8;
+ order: 8;
+ }
+ .order-md-9 {
+ -ms-flex-order: 9;
+ order: 9;
+ }
+ .order-md-10 {
+ -ms-flex-order: 10;
+ order: 10;
+ }
+ .order-md-11 {
+ -ms-flex-order: 11;
+ order: 11;
+ }
+ .order-md-12 {
+ -ms-flex-order: 12;
+ order: 12;
+ }
+ .offset-md-0 {
+ margin-left: 0;
+ }
+ .offset-md-1 {
+ margin-left: 8.333333%;
+ }
+ .offset-md-2 {
+ margin-left: 16.666667%;
+ }
+ .offset-md-3 {
+ margin-left: 25%;
+ }
+ .offset-md-4 {
+ margin-left: 33.333333%;
+ }
+ .offset-md-5 {
+ margin-left: 41.666667%;
+ }
+ .offset-md-6 {
+ margin-left: 50%;
+ }
+ .offset-md-7 {
+ margin-left: 58.333333%;
+ }
+ .offset-md-8 {
+ margin-left: 66.666667%;
+ }
+ .offset-md-9 {
+ margin-left: 75%;
+ }
+ .offset-md-10 {
+ margin-left: 83.333333%;
+ }
+ .offset-md-11 {
+ margin-left: 91.666667%;
+ }
+}
+
+@media (min-width: 992px) {
+ .col-lg {
+ -ms-flex-preferred-size: 0;
+ flex-basis: 0;
+ -ms-flex-positive: 1;
+ flex-grow: 1;
+ max-width: 100%;
+ }
+ .col-lg-auto {
+ -ms-flex: 0 0 auto;
+ flex: 0 0 auto;
+ width: auto;
+ max-width: 100%;
+ }
+ .col-lg-1 {
+ -ms-flex: 0 0 8.333333%;
+ flex: 0 0 8.333333%;
+ max-width: 8.333333%;
+ }
+ .col-lg-2 {
+ -ms-flex: 0 0 16.666667%;
+ flex: 0 0 16.666667%;
+ max-width: 16.666667%;
+ }
+ .col-lg-3 {
+ -ms-flex: 0 0 25%;
+ flex: 0 0 25%;
+ max-width: 25%;
+ }
+ .col-lg-4 {
+ -ms-flex: 0 0 33.333333%;
+ flex: 0 0 33.333333%;
+ max-width: 33.333333%;
+ }
+ .col-lg-5 {
+ -ms-flex: 0 0 41.666667%;
+ flex: 0 0 41.666667%;
+ max-width: 41.666667%;
+ }
+ .col-lg-6 {
+ -ms-flex: 0 0 50%;
+ flex: 0 0 50%;
+ max-width: 50%;
+ }
+ .col-lg-7 {
+ -ms-flex: 0 0 58.333333%;
+ flex: 0 0 58.333333%;
+ max-width: 58.333333%;
+ }
+ .col-lg-8 {
+ -ms-flex: 0 0 66.666667%;
+ flex: 0 0 66.666667%;
+ max-width: 66.666667%;
+ }
+ .col-lg-9 {
+ -ms-flex: 0 0 75%;
+ flex: 0 0 75%;
+ max-width: 75%;
+ }
+ .col-lg-10 {
+ -ms-flex: 0 0 83.333333%;
+ flex: 0 0 83.333333%;
+ max-width: 83.333333%;
+ }
+ .col-lg-11 {
+ -ms-flex: 0 0 91.666667%;
+ flex: 0 0 91.666667%;
+ max-width: 91.666667%;
+ }
+ .col-lg-12 {
+ -ms-flex: 0 0 100%;
+ flex: 0 0 100%;
+ max-width: 100%;
+ }
+ .order-lg-first {
+ -ms-flex-order: -1;
+ order: -1;
+ }
+ .order-lg-last {
+ -ms-flex-order: 13;
+ order: 13;
+ }
+ .order-lg-0 {
+ -ms-flex-order: 0;
+ order: 0;
+ }
+ .order-lg-1 {
+ -ms-flex-order: 1;
+ order: 1;
+ }
+ .order-lg-2 {
+ -ms-flex-order: 2;
+ order: 2;
+ }
+ .order-lg-3 {
+ -ms-flex-order: 3;
+ order: 3;
+ }
+ .order-lg-4 {
+ -ms-flex-order: 4;
+ order: 4;
+ }
+ .order-lg-5 {
+ -ms-flex-order: 5;
+ order: 5;
+ }
+ .order-lg-6 {
+ -ms-flex-order: 6;
+ order: 6;
+ }
+ .order-lg-7 {
+ -ms-flex-order: 7;
+ order: 7;
+ }
+ .order-lg-8 {
+ -ms-flex-order: 8;
+ order: 8;
+ }
+ .order-lg-9 {
+ -ms-flex-order: 9;
+ order: 9;
+ }
+ .order-lg-10 {
+ -ms-flex-order: 10;
+ order: 10;
+ }
+ .order-lg-11 {
+ -ms-flex-order: 11;
+ order: 11;
+ }
+ .order-lg-12 {
+ -ms-flex-order: 12;
+ order: 12;
+ }
+ .offset-lg-0 {
+ margin-left: 0;
+ }
+ .offset-lg-1 {
+ margin-left: 8.333333%;
+ }
+ .offset-lg-2 {
+ margin-left: 16.666667%;
+ }
+ .offset-lg-3 {
+ margin-left: 25%;
+ }
+ .offset-lg-4 {
+ margin-left: 33.333333%;
+ }
+ .offset-lg-5 {
+ margin-left: 41.666667%;
+ }
+ .offset-lg-6 {
+ margin-left: 50%;
+ }
+ .offset-lg-7 {
+ margin-left: 58.333333%;
+ }
+ .offset-lg-8 {
+ margin-left: 66.666667%;
+ }
+ .offset-lg-9 {
+ margin-left: 75%;
+ }
+ .offset-lg-10 {
+ margin-left: 83.333333%;
+ }
+ .offset-lg-11 {
+ margin-left: 91.666667%;
+ }
+}
+
+@media (min-width: 1200px) {
+ .col-xl {
+ -ms-flex-preferred-size: 0;
+ flex-basis: 0;
+ -ms-flex-positive: 1;
+ flex-grow: 1;
+ max-width: 100%;
+ }
+ .col-xl-auto {
+ -ms-flex: 0 0 auto;
+ flex: 0 0 auto;
+ width: auto;
+ max-width: 100%;
+ }
+ .col-xl-1 {
+ -ms-flex: 0 0 8.333333%;
+ flex: 0 0 8.333333%;
+ max-width: 8.333333%;
+ }
+ .col-xl-2 {
+ -ms-flex: 0 0 16.666667%;
+ flex: 0 0 16.666667%;
+ max-width: 16.666667%;
+ }
+ .col-xl-3 {
+ -ms-flex: 0 0 25%;
+ flex: 0 0 25%;
+ max-width: 25%;
+ }
+ .col-xl-4 {
+ -ms-flex: 0 0 33.333333%;
+ flex: 0 0 33.333333%;
+ max-width: 33.333333%;
+ }
+ .col-xl-5 {
+ -ms-flex: 0 0 41.666667%;
+ flex: 0 0 41.666667%;
+ max-width: 41.666667%;
+ }
+ .col-xl-6 {
+ -ms-flex: 0 0 50%;
+ flex: 0 0 50%;
+ max-width: 50%;
+ }
+ .col-xl-7 {
+ -ms-flex: 0 0 58.333333%;
+ flex: 0 0 58.333333%;
+ max-width: 58.333333%;
+ }
+ .col-xl-8 {
+ -ms-flex: 0 0 66.666667%;
+ flex: 0 0 66.666667%;
+ max-width: 66.666667%;
+ }
+ .col-xl-9 {
+ -ms-flex: 0 0 75%;
+ flex: 0 0 75%;
+ max-width: 75%;
+ }
+ .col-xl-10 {
+ -ms-flex: 0 0 83.333333%;
+ flex: 0 0 83.333333%;
+ max-width: 83.333333%;
+ }
+ .col-xl-11 {
+ -ms-flex: 0 0 91.666667%;
+ flex: 0 0 91.666667%;
+ max-width: 91.666667%;
+ }
+ .col-xl-12 {
+ -ms-flex: 0 0 100%;
+ flex: 0 0 100%;
+ max-width: 100%;
+ }
+ .order-xl-first {
+ -ms-flex-order: -1;
+ order: -1;
+ }
+ .order-xl-last {
+ -ms-flex-order: 13;
+ order: 13;
+ }
+ .order-xl-0 {
+ -ms-flex-order: 0;
+ order: 0;
+ }
+ .order-xl-1 {
+ -ms-flex-order: 1;
+ order: 1;
+ }
+ .order-xl-2 {
+ -ms-flex-order: 2;
+ order: 2;
+ }
+ .order-xl-3 {
+ -ms-flex-order: 3;
+ order: 3;
+ }
+ .order-xl-4 {
+ -ms-flex-order: 4;
+ order: 4;
+ }
+ .order-xl-5 {
+ -ms-flex-order: 5;
+ order: 5;
+ }
+ .order-xl-6 {
+ -ms-flex-order: 6;
+ order: 6;
+ }
+ .order-xl-7 {
+ -ms-flex-order: 7;
+ order: 7;
+ }
+ .order-xl-8 {
+ -ms-flex-order: 8;
+ order: 8;
+ }
+ .order-xl-9 {
+ -ms-flex-order: 9;
+ order: 9;
+ }
+ .order-xl-10 {
+ -ms-flex-order: 10;
+ order: 10;
+ }
+ .order-xl-11 {
+ -ms-flex-order: 11;
+ order: 11;
+ }
+ .order-xl-12 {
+ -ms-flex-order: 12;
+ order: 12;
+ }
+ .offset-xl-0 {
+ margin-left: 0;
+ }
+ .offset-xl-1 {
+ margin-left: 8.333333%;
+ }
+ .offset-xl-2 {
+ margin-left: 16.666667%;
+ }
+ .offset-xl-3 {
+ margin-left: 25%;
+ }
+ .offset-xl-4 {
+ margin-left: 33.333333%;
+ }
+ .offset-xl-5 {
+ margin-left: 41.666667%;
+ }
+ .offset-xl-6 {
+ margin-left: 50%;
+ }
+ .offset-xl-7 {
+ margin-left: 58.333333%;
+ }
+ .offset-xl-8 {
+ margin-left: 66.666667%;
+ }
+ .offset-xl-9 {
+ margin-left: 75%;
+ }
+ .offset-xl-10 {
+ margin-left: 83.333333%;
+ }
+ .offset-xl-11 {
+ margin-left: 91.666667%;
+ }
+}
diff --git a/pkg/server/assets/styles/src/_header.scss b/pkg/server/assets/styles/src/_header.scss
new file mode 100644
index 00000000..fbd971b5
--- /dev/null
+++ b/pkg/server/assets/styles/src/_header.scss
@@ -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);
+ }
+ }
+}
diff --git a/pkg/server/assets/styles/src/_hljs.scss b/pkg/server/assets/styles/src/_hljs.scss
new file mode 100644
index 00000000..03636f1a
--- /dev/null
+++ b/pkg/server/assets/styles/src/_hljs.scss
@@ -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 .
+ */
+
+/*
+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;
+}
diff --git a/pkg/server/assets/styles/src/_home.scss b/pkg/server/assets/styles/src/_home.scss
new file mode 100644
index 00000000..282589d2
--- /dev/null
+++ b/pkg/server/assets/styles/src/_home.scss
@@ -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;
+ }
+ }
+}
diff --git a/pkg/server/assets/styles/src/_login.scss b/pkg/server/assets/styles/src/_login.scss
new file mode 100644
index 00000000..673813e3
--- /dev/null
+++ b/pkg/server/assets/styles/src/_login.scss
@@ -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;
+ }
+}
diff --git a/pkg/server/assets/styles/src/_markdown.scss b/pkg/server/assets/styles/src/_markdown.scss
new file mode 100644
index 00000000..99b3e92f
--- /dev/null
+++ b/pkg/server/assets/styles/src/_markdown.scss
@@ -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 .
+ */
+
+/*
+MIT License
+
+Copyright (c) Sindre Sorhus (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;
+}
diff --git a/pkg/server/assets/styles/src/_marker.scss b/pkg/server/assets/styles/src/_marker.scss
new file mode 100644
index 00000000..75afcc10
--- /dev/null
+++ b/pkg/server/assets/styles/src/_marker.scss
@@ -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 .
+ */
+
+.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;
+}
diff --git a/pkg/server/assets/styles/src/_note.scss b/pkg/server/assets/styles/src/_note.scss
new file mode 100644
index 00000000..a903a66e
--- /dev/null
+++ b/pkg/server/assets/styles/src/_note.scss
@@ -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);
+ }
+ }
+ }
+}
diff --git a/pkg/server/assets/styles/src/_reboot.scss b/pkg/server/assets/styles/src/_reboot.scss
new file mode 100644
index 00000000..174f2989
--- /dev/null
+++ b/pkg/server/assets/styles/src/_reboot.scss
@@ -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 .
+ */
+
+/*!
+ * 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 */
diff --git a/pkg/server/assets/styles/src/_rem.scss b/pkg/server/assets/styles/src/_rem.scss
new file mode 100644
index 00000000..6c9d98a9
--- /dev/null
+++ b/pkg/server/assets/styles/src/_rem.scss
@@ -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 .
+ */
+
+/*
+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...);
+ }
+ }
+ }
+}
diff --git a/pkg/server/assets/styles/src/_responsive.scss b/pkg/server/assets/styles/src/_responsive.scss
new file mode 100644
index 00000000..caf5bc12
--- /dev/null
+++ b/pkg/server/assets/styles/src/_responsive.scss
@@ -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 .
+ */
+
+@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;
+ }
+}
diff --git a/pkg/server/assets/styles/src/_select.scss b/pkg/server/assets/styles/src/_select.scss
new file mode 100644
index 00000000..f534a64a
--- /dev/null
+++ b/pkg/server/assets/styles/src/_select.scss
@@ -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 .
+ */
+
+/**
+ * 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);
+ }
+}
diff --git a/pkg/server/assets/styles/src/_settings.scss b/pkg/server/assets/styles/src/_settings.scss
new file mode 100644
index 00000000..81c2ac43
--- /dev/null
+++ b/pkg/server/assets/styles/src/_settings.scss
@@ -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);
+ }
+}
diff --git a/pkg/server/assets/styles/src/_shared.scss b/pkg/server/assets/styles/src/_shared.scss
new file mode 100644
index 00000000..773706bb
--- /dev/null
+++ b/pkg/server/assets/styles/src/_shared.scss
@@ -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 .
+ */
+
+@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;
+}
diff --git a/pkg/server/assets/styles/src/_theme.scss b/pkg/server/assets/styles/src/_theme.scss
new file mode 100644
index 00000000..a3e09996
--- /dev/null
+++ b/pkg/server/assets/styles/src/_theme.scss
@@ -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 .
+ */
+
+// 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;
diff --git a/pkg/server/api/health.go b/pkg/server/assets/styles/src/_variables.scss
similarity index 76%
rename from pkg/server/api/health.go
rename to pkg/server/assets/styles/src/_variables.scss
index 3480b796..808e20f7 100644
--- a/pkg/server/api/health.go
+++ b/pkg/server/assets/styles/src/_variables.scss
@@ -16,13 +16,16 @@
* along with Dnote. If not, see .
*/
-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;
}
diff --git a/pkg/server/assets/styles/src/main.scss b/pkg/server/assets/styles/src/main.scss
new file mode 100644
index 00000000..bf1af413
--- /dev/null
+++ b/pkg/server/assets/styles/src/main.scss
@@ -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 .
+ */
+
+@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;
+}
diff --git a/pkg/server/buildinfo/info.go b/pkg/server/buildinfo/info.go
new file mode 100644
index 00000000..0e7d201f
--- /dev/null
+++ b/pkg/server/buildinfo/info.go
@@ -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"
+)
diff --git a/pkg/server/config/config.go b/pkg/server/config/config.go
index c3e5eaaa..a5d2cc3b 100644
--- a/pkg/server/config/config.go
+++ b/pkg/server/config/config.go
@@ -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)
diff --git a/pkg/server/consts/consts.go b/pkg/server/consts/consts.go
new file mode 100644
index 00000000..8b6e83ab
--- /dev/null
+++ b/pkg/server/consts/consts.go
@@ -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"
+)
diff --git a/pkg/server/context/user.go b/pkg/server/context/user.go
new file mode 100644
index 00000000..81e6ba8c
--- /dev/null
+++ b/pkg/server/context/user.go
@@ -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
+}
diff --git a/pkg/server/controllers/books.go b/pkg/server/controllers/books.go
new file mode 100644
index 00000000..5a937718
--- /dev/null
+++ b/pkg/server/controllers/books.go
@@ -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, ¶ms); 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, ¶ms); 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(¬es).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")
+}
diff --git a/pkg/server/api/v3_books_test.go b/pkg/server/controllers/books_test.go
similarity index 65%
rename from pkg/server/api/v3_books_test.go
rename to pkg/server/controllers/books_test.go
index 4cd6d579..5c177f70 100644
--- a/pkg/server/api/v3_books_test.go
+++ b/pkg/server/controllers/books_test.go
@@ -16,17 +16,19 @@
* along with Dnote. If not, see .
*/
-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(¬eCount), "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(¬eCount), "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(¬eCount), "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(¬eCount), "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(¬eCount), "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(¬eCount), "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))
- }()
- }
-}
diff --git a/pkg/server/controllers/controllers.go b/pkg/server/controllers/controllers.go
new file mode 100644
index 00000000..5786991b
--- /dev/null
+++ b/pkg/server/controllers/controllers.go
@@ -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
+}
diff --git a/pkg/server/controllers/health.go b/pkg/server/controllers/health.go
new file mode 100644
index 00000000..0e0608d6
--- /dev/null
+++ b/pkg/server/controllers/health.go
@@ -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"))
+}
diff --git a/pkg/server/api/health_test.go b/pkg/server/controllers/health_test.go
similarity index 80%
rename from pkg/server/api/health_test.go
rename to pkg/server/controllers/health_test.go
index 0deadc5e..9e5b53b8 100644
--- a/pkg/server/api/health_test.go
+++ b/pkg/server/controllers/health_test.go
@@ -16,29 +16,30 @@
* along with Dnote. If not, see .
*/
-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
diff --git a/pkg/server/controllers/helpers.go b/pkg/server/controllers/helpers.go
new file mode 100644
index 00000000..7a3cb9b5
--- /dev/null
+++ b/pkg/server/controllers/helpers.go
@@ -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"
+}
diff --git a/pkg/server/job/remind/main_test.go b/pkg/server/controllers/main_test.go
similarity index 97%
rename from pkg/server/job/remind/main_test.go
rename to pkg/server/controllers/main_test.go
index d8b6b62a..286b94ee 100644
--- a/pkg/server/job/remind/main_test.go
+++ b/pkg/server/controllers/main_test.go
@@ -16,7 +16,7 @@
* along with Dnote. If not, see .
*/
-package remind
+package controllers
import (
"os"
diff --git a/pkg/server/controllers/notes.go b/pkg/server/controllers/notes.go
new file mode 100644
index 00000000..72d41817
--- /dev/null
+++ b/pkg/server/controllers/notes.go
@@ -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, ¶ms); 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(¬e).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, ¶ms)
+ 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(¬e).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")
+}
diff --git a/pkg/server/controllers/notes_test.go b/pkg/server/controllers/notes_test.go
new file mode 100644
index 00000000..c6fa1ffe
--- /dev/null
+++ b/pkg/server/controllers/notes_test.go
@@ -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 .
+ */
+
+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(¬eCount), "counting notes")
+ testutils.MustExec(t, testutils.DB.First(¬eRecord), "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(¬e), "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(¬eCount), "counting notes")
+ testutils.MustExec(t, testutils.DB.Where("uuid = ?", note.UUID).First(¬eRecord), "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(¬e), "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(¬eCount), "counting notes")
+ testutils.MustExec(t, testutils.DB.Where("uuid = ?", note.UUID).First(¬eRecord), "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")
+ })
+ }
+}
diff --git a/pkg/server/controllers/routes.go b/pkg/server/controllers/routes.go
new file mode 100644
index 00000000..af76bf76
--- /dev/null
+++ b/pkg/server/controllers/routes.go
@@ -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
+}
diff --git a/pkg/server/controllers/routes_test.go b/pkg/server/controllers/routes_test.go
new file mode 100644
index 00000000..5f09b0d1
--- /dev/null
+++ b/pkg/server/controllers/routes_test.go
@@ -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 .
+ */
+
+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")
+ })
+ }
+}
diff --git a/pkg/server/controllers/static.go b/pkg/server/controllers/static.go
new file mode 100644
index 00000000..6641c6fc
--- /dev/null
+++ b/pkg/server/controllers/static.go
@@ -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))
+ }
+}
diff --git a/pkg/server/api/v3_sync.go b/pkg/server/controllers/sync.go
similarity index 83%
rename from pkg/server/api/v3_sync.go
rename to pkg/server/controllers/sync.go
index f30f4273..76e7f742 100644
--- a/pkg/server/api/v3_sync.go
+++ b/pkg/server/controllers/sync.go
@@ -16,7 +16,7 @@
* along with Dnote. If not, see .
*/
-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(¬es).Error; err != nil {
+ if err := s.app.DB.Where("user_id = ? AND usn > ? AND usn <= ?", userID, afterUSN, userMaxUSN).Order("usn ASC").Limit(limit).Find(¬es).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)
}
diff --git a/pkg/server/api/v3_sync_test.go b/pkg/server/controllers/sync_test.go
similarity index 99%
rename from pkg/server/api/v3_sync_test.go
rename to pkg/server/controllers/sync_test.go
index 8f2fb6e6..e8e48da1 100644
--- a/pkg/server/api/v3_sync_test.go
+++ b/pkg/server/controllers/sync_test.go
@@ -16,7 +16,7 @@
* along with Dnote. If not, see .
*/
-package api
+package controllers
import (
"fmt"
diff --git a/pkg/server/api/testutils.go b/pkg/server/controllers/testutils.go
similarity index 67%
rename from pkg/server/api/testutils.go
rename to pkg/server/controllers/testutils.go
index fe17e25c..aaf4f5c7 100644
--- a/pkg/server/api/testutils.go
+++ b/pkg/server/controllers/testutils.go
@@ -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 .
*/
-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
}
diff --git a/pkg/server/controllers/users.go b/pkg/server/controllers/users.go
new file mode 100644
index 00000000..ba8cdca1
--- /dev/null
+++ b/pkg/server/controllers/users.go
@@ -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, ¶ms); 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)
+}
diff --git a/pkg/server/controllers/users_test.go b/pkg/server/controllers/users_test.go
new file mode 100644
index 00000000..110e61b1
--- /dev/null
+++ b/pkg/server/controllers/users_test.go
@@ -0,0 +1,1388 @@
+/* Copyright (C) 2019, 2020 Monomax Software Pty Ltd
+ *
+ * This file is part of Dnote.
+ *
+ * Dnote is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Dnote is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Dnote. If not, see .
+ */
+
+package controllers
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "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 assertResponseSessionCookie(t *testing.T, res *http.Response) {
+ 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")
+
+ 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 TestJoin(t *testing.T) {
+ testCases := []struct {
+ email string
+ password string
+ passwordConfirmation string
+ onPremise bool
+ expectedPro bool
+ }{
+ {
+ email: "alice@example.com",
+ password: "pass1234",
+ passwordConfirmation: "pass1234",
+ onPremise: false,
+ expectedPro: false,
+ },
+ {
+ email: "bob@example.com",
+ password: "Y9EwmjH@Jq6y5a64MSACUoM4w7SAhzvY",
+ passwordConfirmation: "Y9EwmjH@Jq6y5a64MSACUoM4w7SAhzvY",
+ onPremise: false,
+ expectedPro: false,
+ },
+ {
+ email: "chuck@example.com",
+ password: "e*H@kJi^vXbWEcD9T5^Am!Y@7#Po2@PC",
+ passwordConfirmation: "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",
+ passwordConfirmation: "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)
+
+ // Setup
+ emailBackend := testutils.MockEmailbackendImplementation{}
+ server := MustNewServer(t, &app.App{
+ Clock: clock.NewMock(),
+ EmailBackend: &emailBackend,
+ Config: config.Config{
+ OnPremise: tc.onPremise,
+ PageTemplateDir: "../views",
+ },
+ })
+ defer server.Close()
+
+ dat := url.Values{}
+ dat.Set("email", tc.email)
+ dat.Set("password", tc.password)
+ dat.Set("password_confirmation", tc.passwordConfirmation)
+ req := testutils.MakeFormReq(server.URL, "POST", "/join", dat)
+
+ // Execute
+ res := testutils.HTTPDo(t, req)
+
+ // Test
+ assert.StatusCodeEquals(t, res, http.StatusFound, "")
+
+ 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
+ assertResponseSessionCookie(t, res)
+ })
+ }
+}
+
+func TestJoinError(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(),
+ Config: config.Config{
+ PageTemplateDir: "../views",
+ },
+ })
+ defer server.Close()
+
+ dat := url.Values{}
+ dat.Set("password", "SLMZFM5RmSjA5vfXnG5lPOnrpZSbtmV76cnAcrlr2yU")
+ req := testutils.MakeFormReq(server.URL, "POST", "/join", 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(),
+ Config: config.Config{
+ PageTemplateDir: "../views",
+ },
+ })
+ defer server.Close()
+
+ dat := url.Values{}
+ dat.Set("email", "alice@example.com")
+ req := testutils.MakeFormReq(server.URL, "POST", "/join", 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("password confirmation mismatch", 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()
+
+ dat := url.Values{}
+ dat.Set("email", "alice@example.com")
+ dat.Set("password", "pass1234")
+ dat.Set("password_confirmation", "1234pass")
+ req := testutils.MakeFormReq(server.URL, "POST", "/join", 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 TestJoinDuplicateEmail(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()
+
+ u := testutils.SetupUserData()
+ testutils.SetupAccountData(u, "alice@example.com", "somepassword")
+
+ dat := url.Values{}
+ dat.Set("email", "alice@example.com")
+ dat.Set("password", "foobarbaz")
+ dat.Set("password_confirmation", "foobarbaz")
+ req := testutils.MakeFormReq(server.URL, "POST", "/join", 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 TestJoinDisabled(t *testing.T) {
+ defer testutils.ClearData(testutils.DB)
+
+ // Setup
+ server := MustNewServer(t, &app.App{
+ Clock: clock.NewMock(),
+ Config: config.Config{
+ PageTemplateDir: "../views",
+ DisableRegistration: true,
+ },
+ })
+ defer server.Close()
+
+ dat := url.Values{}
+ dat.Set("email", "alice@example.com")
+ dat.Set("password", "foobarbaz")
+ req := testutils.MakeFormReq(server.URL, "POST", "/join", dat)
+
+ // Execute
+ res := testutils.HTTPDo(t, req)
+
+ // Test
+ assert.StatusCodeEquals(t, res, http.StatusNotFound, "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 TestLogin(t *testing.T) {
+ testutils.RunForWebAndAPI(t, "success", func(t *testing.T, target testutils.EndpointType) {
+ defer testutils.ClearData(testutils.DB)
+
+ // Setup
+ server := MustNewServer(t, &app.App{
+ Clock: clock.NewMock(),
+ Config: config.Config{
+ PageTemplateDir: "../views",
+ },
+ })
+
+ u := testutils.SetupUserData()
+ testutils.SetupAccountData(u, "alice@example.com", "pass1234")
+ defer server.Close()
+
+ // Execute
+ var req *http.Request
+ if target == testutils.EndpointWeb {
+ dat := url.Values{}
+ dat.Set("email", "alice@example.com")
+ dat.Set("password", "pass1234")
+ req = testutils.MakeFormReq(server.URL, "POST", "/login", dat)
+ } else {
+ dat := `{"email": "alice@example.com", "password": "pass1234"}`
+ req = testutils.MakeReq(server.URL, "POST", "/api/v3/signin", dat)
+ }
+
+ res := testutils.HTTPDo(t, req)
+
+ // Test
+ if target == testutils.EndpointWeb {
+ assert.StatusCodeEquals(t, res, http.StatusFound, "")
+ } else {
+ 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")
+
+ if target == testutils.EndpointWeb {
+ assertResponseSessionCookie(t, res)
+ } else {
+ // 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")
+
+ assertResponseSessionCookie(t, res)
+ }
+ })
+
+ testutils.RunForWebAndAPI(t, "wrong password", func(t *testing.T, target testutils.EndpointType) {
+ defer testutils.ClearData(testutils.DB)
+
+ // Setup
+ server := MustNewServer(t, &app.App{
+ Clock: clock.NewMock(),
+ Config: config.Config{
+ PageTemplateDir: "../views",
+ },
+ })
+
+ u := testutils.SetupUserData()
+ testutils.SetupAccountData(u, "alice@example.com", "pass1234")
+ defer server.Close()
+
+ var req *http.Request
+ if target == testutils.EndpointWeb {
+ dat := url.Values{}
+ dat.Set("email", "alice@example.com")
+ dat.Set("password", "wrongpassword1234")
+ req = testutils.MakeFormReq(server.URL, "POST", "/login", dat)
+ } else {
+ dat := `{"email": "alice@example.com", "password": "wrongpassword1234"}`
+ req = testutils.MakeReq(server.URL, "POST", "/api/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")
+ })
+
+ testutils.RunForWebAndAPI(t, "wrong email", func(t *testing.T, target testutils.EndpointType) {
+ defer testutils.ClearData(testutils.DB)
+
+ // Setup
+ server := MustNewServer(t, &app.App{
+ Clock: clock.NewMock(),
+ Config: config.Config{
+ PageTemplateDir: "../views",
+ },
+ })
+ defer server.Close()
+
+ u := testutils.SetupUserData()
+ testutils.SetupAccountData(u, "alice@example.com", "pass1234")
+
+ var req *http.Request
+ if target == testutils.EndpointWeb {
+ dat := url.Values{}
+ dat.Set("email", "bob@example.com")
+ dat.Set("password", "foobarbaz")
+ req = testutils.MakeFormReq(server.URL, "POST", "/login", dat)
+ } else {
+ dat := `{"email": "bob@example.com", "password": "foobarbaz"}`
+ req = testutils.MakeReq(server.URL, "POST", "/api/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")
+ })
+
+ testutils.RunForWebAndAPI(t, "nonexistent email", func(t *testing.T, target testutils.EndpointType) {
+ defer testutils.ClearData(testutils.DB)
+
+ // Setup
+ server := MustNewServer(t, &app.App{
+ Clock: clock.NewMock(),
+ Config: config.Config{
+ PageTemplateDir: "../views",
+ },
+ })
+ defer server.Close()
+
+ var req *http.Request
+ if target == testutils.EndpointWeb {
+ dat := url.Values{}
+ dat.Set("email", "nonexistent@example.com")
+ dat.Set("password", "pass1234")
+ req = testutils.MakeFormReq(server.URL, "POST", "/login", dat)
+ } else {
+ dat := `{"email": "nonexistent@example.com", "password": "pass1234"}`
+ req = testutils.MakeReq(server.URL, "POST", "/api/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 TestLogout(t *testing.T) {
+ setupLogoutTest := func(t *testing.T) (*httptest.Server, *database.Session, *database.Session) {
+ // Setup
+ server := MustNewServer(t, &app.App{
+ Clock: clock.NewMock(),
+ Config: config.Config{
+ PageTemplateDir: "../views",
+ },
+ })
+
+ aliceUser := testutils.SetupUserData()
+ testutils.SetupAccountData(aliceUser, "alice@example.com", "pass1234")
+ anotherUser := testutils.SetupUserData()
+
+ session1ExpiresAt := time.Now().Add(time.Hour * 24)
+ session1 := database.Session{
+ Key: "A9xgggqzTHETy++GDi1NpDNe0iyqosPm9bitdeNGkJU=",
+ UserID: aliceUser.ID,
+ ExpiresAt: session1ExpiresAt,
+ }
+ 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")
+
+ return server, &session1, &session2
+ }
+
+ testutils.RunForWebAndAPI(t, "authenticated", func(t *testing.T, target testutils.EndpointType) {
+ defer testutils.ClearData(testutils.DB)
+
+ server, session1, _ := setupLogoutTest(t)
+ defer server.Close()
+
+ // Execute
+ var req *http.Request
+ if target == testutils.EndpointWeb {
+ dat := url.Values{}
+ req = testutils.MakeFormReq(server.URL, "POST", "/logout", dat)
+ req.AddCookie(&http.Cookie{Name: "id", Value: "A9xgggqzTHETy++GDi1NpDNe0iyqosPm9bitdeNGkJU=", Expires: session1.ExpiresAt, Path: "/", HttpOnly: true})
+ } else {
+ req = testutils.MakeReq(server.URL, "POST", "/api/v3/signout", "")
+ req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", session1.Key))
+ }
+
+ res := testutils.HTTPDo(t, req)
+
+ // Test
+ if target == testutils.EndpointWeb {
+ assert.StatusCodeEquals(t, res, http.StatusFound, "Status mismatch")
+ } else {
+ 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")
+
+ if target == testutils.EndpointWeb {
+ 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")
+ }
+ }
+ })
+
+ testutils.RunForWebAndAPI(t, "unauthenticated", func(t *testing.T, target testutils.EndpointType) {
+ defer testutils.ClearData(testutils.DB)
+
+ server, _, _ := setupLogoutTest(t)
+ defer server.Close()
+
+ // Execute
+ var req *http.Request
+ if target == testutils.EndpointWeb {
+ dat := url.Values{}
+ req = testutils.MakeFormReq(server.URL, "POST", "/logout", dat)
+ } else {
+ req = testutils.MakeReq(server.URL, "POST", "/api/v3/signout", "")
+ }
+
+ res := testutils.HTTPDo(t, req)
+
+ // Test
+ if target == testutils.EndpointWeb {
+ assert.StatusCodeEquals(t, res, http.StatusFound, "Status mismatch")
+ } else {
+ 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")
+ })
+}
+
+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(),
+ Config: config.Config{
+ PageTemplateDir: "../views",
+ },
+ })
+ 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")
+
+ 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
+ dat := url.Values{}
+ dat.Set("token", "MivFxYiSMMA4An9dP24DNQ==")
+ dat.Set("password", "newpassword")
+ dat.Set("password_confirmation", "newpassword")
+ req := testutils.MakeFormReq(server.URL, "PATCH", "/password-reset", dat)
+
+ res := testutils.HTTPDo(t, req)
+
+ // Test
+ assert.StatusCodeEquals(t, res, http.StatusFound, "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, 0, "should have deleted a 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(),
+ Config: config.Config{
+ PageTemplateDir: "../views",
+ },
+ })
+ 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 := url.Values{}
+ dat.Set("token", "-ApMnyvpg59uOU5b-Kf5uQ==")
+ dat.Set("password", "oldpassword")
+ dat.Set("password_confirmation", "oldpassword")
+ req := testutils.MakeFormReq(server.URL, "PATCH", "/password-reset", 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(),
+ Config: config.Config{
+ PageTemplateDir: "../views",
+ },
+ })
+ 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 := url.Values{}
+ dat.Set("token", "MivFxYiSMMA4An9dP24DNQ==")
+ dat.Set("password", "oldpassword")
+ dat.Set("password_confirmation", "oldpassword")
+ req := testutils.MakeFormReq(server.URL, "PATCH", "/password-reset", 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(),
+ Config: config.Config{
+ PageTemplateDir: "../views",
+ },
+ })
+ 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 := url.Values{}
+ dat.Set("token", "MivFxYiSMMA4An9dP24DNQ==")
+ dat.Set("password", "oldpassword")
+ dat.Set("password_confirmation", "oldpassword")
+ req := testutils.MakeFormReq(server.URL, "PATCH", "/password-reset", 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(),
+ Config: config.Config{
+ PageTemplateDir: "../views",
+ },
+ })
+ 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 := url.Values{}
+ dat.Set("token", "MivFxYiSMMA4An9dP24DNQ==")
+ dat.Set("password", "oldpassword")
+ dat.Set("password_confirmation", "oldpassword")
+ req := testutils.MakeFormReq(server.URL, "PATCH", "/password-reset", 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")
+ })
+}
+
+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(),
+ Config: config.Config{
+ PageTemplateDir: "../views",
+ },
+ })
+ defer server.Close()
+
+ u := testutils.SetupUserData()
+ testutils.SetupAccountData(u, "alice@example.com", "somepassword")
+
+ // Execute
+ dat := url.Values{}
+ dat.Set("email", "alice@example.com")
+ req := testutils.MakeFormReq(server.URL, "POST", "/reset-token", dat)
+
+ res := testutils.HTTPDo(t, req)
+
+ // Test
+ assert.StatusCodeEquals(t, res, http.StatusFound, "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(),
+ Config: config.Config{
+ PageTemplateDir: "../views",
+ },
+ })
+ defer server.Close()
+
+ u := testutils.SetupUserData()
+ testutils.SetupAccountData(u, "alice@example.com", "somepassword")
+
+ // Execute
+ dat := url.Values{}
+ dat.Set("email", "bob@example.com")
+ req := testutils.MakeFormReq(server.URL, "POST", "/reset-token", dat)
+
+ 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 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(),
+ Config: config.Config{
+ PageTemplateDir: "../views",
+ },
+ })
+ defer server.Close()
+
+ user := testutils.SetupUserData()
+ testutils.SetupAccountData(user, "alice@example.com", "oldpassword")
+
+ // Execute
+ dat := url.Values{}
+ dat.Set("old_password", "oldpassword")
+ dat.Set("new_password", "newpassword")
+ dat.Set("new_password_confirmation", "newpassword")
+ req := testutils.MakeFormReq(server.URL, "PATCH", "/account/password", dat)
+
+ res := testutils.HTTPAuthDo(t, req, user)
+
+ // Test
+ assert.StatusCodeEquals(t, res, http.StatusFound, "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(),
+ Config: config.Config{
+ PageTemplateDir: "../views",
+ },
+ })
+ defer server.Close()
+
+ u := testutils.SetupUserData()
+ a := testutils.SetupAccountData(u, "alice@example.com", "oldpassword")
+
+ // Execute
+ dat := url.Values{}
+ dat.Set("old_password", "randompassword")
+ dat.Set("new_password", "newpassword")
+ dat.Set("new_password_confirmation", "newpassword")
+ req := testutils.MakeFormReq(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(),
+ Config: config.Config{
+ PageTemplateDir: "../views",
+ },
+ })
+ defer server.Close()
+
+ u := testutils.SetupUserData()
+ a := testutils.SetupAccountData(u, "alice@example.com", "oldpassword")
+
+ // Execute
+ dat := url.Values{}
+ dat.Set("old_password", "oldpassword")
+ dat.Set("new_password", "a")
+ dat.Set("new_password_confirmation", "a")
+ req := testutils.MakeFormReq(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")
+ })
+
+ t.Run("password confirmation mismatch", 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()
+
+ u := testutils.SetupUserData()
+ a := testutils.SetupAccountData(u, "alice@example.com", "oldpassword")
+
+ // Execute
+ dat := url.Values{}
+ dat.Set("old_password", "oldpassword")
+ dat.Set("new_password", "newpassword1")
+ dat.Set("new_password_confirmation", "newpassword2")
+ req := testutils.MakeFormReq(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 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(),
+ Config: config.Config{
+ PageTemplateDir: "../views",
+ },
+ })
+ 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 := url.Values{}
+ dat.Set("email", "alice-new@example.com")
+ dat.Set("password", "pass1234")
+ req := testutils.MakeFormReq(server.URL, "PATCH", "/account/profile", dat)
+
+ res := testutils.HTTPAuthDo(t, req, u)
+
+ // Test
+ assert.StatusCodeEquals(t, res, http.StatusFound, "Status code mismatch")
+
+ 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(),
+ Config: config.Config{
+ PageTemplateDir: "../views",
+ },
+ })
+ 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 := url.Values{}
+ dat.Set("email", "alice-new@example.com")
+ dat.Set("password", "wrongpassword")
+ req := testutils.MakeFormReq(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 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(),
+ Config: config.Config{
+ PageTemplateDir: "../views",
+ },
+ })
+ 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")
+
+ // Execute
+ req := testutils.MakeReq(server.URL, "GET", fmt.Sprintf("/verify-email/%s", "someTokenValue"), "")
+ res := testutils.HTTPAuthDo(t, req, user)
+
+ // Test
+ assert.StatusCodeEquals(t, res, http.StatusFound, "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(),
+ Config: config.Config{
+ PageTemplateDir: "../views",
+ },
+ })
+ 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")
+
+ // Execute
+ req := testutils.MakeReq(server.URL, "GET", fmt.Sprintf("/verify-email/%s", "someTokenValue"), "")
+ 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(),
+ Config: config.Config{
+ PageTemplateDir: "../views",
+ },
+ })
+ 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")
+
+ // Execute
+ req := testutils.MakeReq(server.URL, "GET", fmt.Sprintf("/verify-email/%s", "someTokenValue"), "")
+ 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(),
+ Config: config.Config{
+ PageTemplateDir: "../views",
+ },
+ })
+ 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")
+
+ // Execute
+ req := testutils.MakeReq(server.URL, "GET", fmt.Sprintf("/verify-email/%s", "someTokenValue"), "")
+ 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 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(),
+ Config: config.Config{
+ PageTemplateDir: "../views",
+ },
+ 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.StatusFound, "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(),
+ Config: config.Config{
+ PageTemplateDir: "../views",
+ },
+ })
+ 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.StatusConflict, "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")
+ })
+}
diff --git a/pkg/server/database/errors.go b/pkg/server/database/errors.go
new file mode 100644
index 00000000..e142a83d
--- /dev/null
+++ b/pkg/server/database/errors.go
@@ -0,0 +1,12 @@
+package database
+
+import (
+ "github.com/pkg/errors"
+)
+
+type modelError string
+
+var (
+ // ErrNotFound an error that indicates that the given resource is not found
+ ErrNotFound error = errors.New("not found")
+)
diff --git a/pkg/server/handlers/main_test.go b/pkg/server/helpers/url.go
similarity index 70%
rename from pkg/server/handlers/main_test.go
rename to pkg/server/helpers/url.go
index 550c919e..e136b230 100644
--- a/pkg/server/handlers/main_test.go
+++ b/pkg/server/helpers/url.go
@@ -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,20 +16,19 @@
* along with Dnote. If not, see .
*/
-package handlers
+package helpers
import (
- "os"
- "testing"
-
- "github.com/dnote/dnote/pkg/server/testutils"
+ "fmt"
+ "net/url"
)
-func TestMain(m *testing.M) {
- testutils.InitTestDB()
+// GetPath returns a path optionally suffixed by query string
+func GetPath(path string, query *url.Values) string {
+ if query == nil {
+ return path
+ }
- code := m.Run()
- testutils.ClearData(testutils.DB)
-
- os.Exit(code)
+ q := query.Encode()
+ return fmt.Sprintf("%s?%s", path, q)
}
diff --git a/pkg/server/helpers/url_test.go b/pkg/server/helpers/url_test.go
new file mode 100644
index 00000000..14752e93
--- /dev/null
+++ b/pkg/server/helpers/url_test.go
@@ -0,0 +1,29 @@
+package helpers
+
+import (
+ "net/url"
+ "testing"
+
+ "github.com/dnote/dnote/pkg/assert"
+)
+
+func TestGetPath(t *testing.T) {
+ t.Run("without query", func(t *testing.T) {
+ // execute
+ got := GetPath("/some-path", nil)
+
+ // test
+ assert.Equal(t, got, "/some-path", "got mismatch")
+ })
+
+ t.Run("with query", func(t *testing.T) {
+ // execute
+ q := url.Values{}
+ q.Set("foo", "bar")
+ q.Set("baz", "/quz")
+ got := GetPath("/some-path", &q)
+
+ // test
+ assert.Equal(t, got, "/some-path?baz=%2Fquz&foo=bar", "got mismatch")
+ })
+}
diff --git a/pkg/server/helpers/helpers.go b/pkg/server/helpers/uuid.go
similarity index 100%
rename from pkg/server/helpers/helpers.go
rename to pkg/server/helpers/uuid.go
diff --git a/pkg/server/job/job.go b/pkg/server/job/job.go
index 45990a49..6f069ad6 100644
--- a/pkg/server/job/job.go
+++ b/pkg/server/job/job.go
@@ -23,8 +23,6 @@ import (
"github.com/dnote/dnote/pkg/clock"
"github.com/dnote/dnote/pkg/server/config"
- "github.com/dnote/dnote/pkg/server/job/remind"
- "github.com/dnote/dnote/pkg/server/log"
"github.com/dnote/dnote/pkg/server/mailer"
"github.com/jinzhu/gorm"
"github.com/pkg/errors"
@@ -102,7 +100,6 @@ func scheduleJob(c *cron.Cron, spec string, cmd func()) {
func (r *Runner) schedule(ch chan error) {
// Schedule jobs
cr := cron.New()
- scheduleJob(cr, "0 8 * * *", func() { r.RemindNoRecentNotes() })
cr.Start()
ch <- nil
@@ -128,26 +125,3 @@ func (r *Runner) Do() error {
return nil
}
-
-// RemindNoRecentNotes remind users if no notes have been added recently
-func (r *Runner) RemindNoRecentNotes() {
- c := remind.Context{
- DB: r.DB,
- Clock: r.Clock,
- EmailTmpl: r.EmailTmpl,
- EmailBackend: r.EmailBackend,
- Config: r.Config,
- }
-
- result, err := remind.DoInactive(c)
- m := log.WithFields(log.Fields{
- "success_count": result.SuccessCount,
- "failed_user_ids": result.FailedUserIDs,
- })
-
- if err == nil {
- m.Info("successfully processed no recent note reminder job")
- } else {
- m.ErrorWrap(err, "error processing no recent note reminder job")
- }
-}
diff --git a/pkg/server/job/remind/inactive.go b/pkg/server/job/remind/inactive.go
deleted file mode 100644
index d0e9de01..00000000
--- a/pkg/server/job/remind/inactive.go
+++ /dev/null
@@ -1,210 +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 .
- */
-
-package remind
-
-import (
- "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/log"
- "github.com/dnote/dnote/pkg/server/mailer"
- "github.com/jinzhu/gorm"
- "github.com/pkg/errors"
-)
-
-// Context holds data that repetition job needs in order to perform
-type Context struct {
- DB *gorm.DB
- Clock clock.Clock
- EmailTmpl mailer.Templates
- EmailBackend mailer.Backend
- Config config.Config
-}
-
-type inactiveUserInfo struct {
- userID int
- email string
- sampleNoteUUID string
-}
-
-func (c *Context) sampleUserNote(userID int) (database.Note, error) {
- var ret database.Note
- // FIXME: ordering by random() requires a sequential scan on the whole table and does not scale
- if err := c.DB.Where("user_id = ?", userID).Order("random() DESC").First(&ret).Error; err != nil {
- return ret, errors.Wrap(err, "getting a random note")
- }
-
- return ret, nil
-}
-
-func (c *Context) getInactiveUserInfo() ([]inactiveUserInfo, error) {
- ret := []inactiveUserInfo{}
-
- threshold := c.Clock.Now().AddDate(0, 0, -14).Unix()
-
- rows, err := c.DB.Raw(`
-SELECT
- notes.user_id AS user_id,
- accounts.email,
- SUM(
- CASE
- WHEN notes.created_at > to_timestamp(?) THEN 1
- ELSE 0
- END
- ) AS recent_note_count,
- COUNT(*) AS total_note_count
-FROM notes
-INNER JOIN accounts ON accounts.user_id = notes.user_id
-WHERE accounts.email IS NOT NULL AND accounts.email_verified IS TRUE
-GROUP BY notes.user_id, accounts.email`, threshold).Rows()
- if err != nil {
- return ret, errors.Wrap(err, "executing note count SQL query")
- }
- defer rows.Close()
- for rows.Next() {
- var userID, recentNoteCount, totalNoteCount int
- var email string
- if err := rows.Scan(&userID, &email, &recentNoteCount, &totalNoteCount); err != nil {
- return nil, errors.Wrap(err, "scanning a row")
- }
-
- if recentNoteCount == 0 && totalNoteCount > 0 {
- note, err := c.sampleUserNote(userID)
- if err != nil {
- return nil, errors.Wrap(err, "sampling user note")
- }
-
- ret = append(ret, inactiveUserInfo{
- userID: userID,
- email: email,
- sampleNoteUUID: note.UUID,
- })
- }
- }
-
- return ret, nil
-}
-
-func (c *Context) canNotify(info inactiveUserInfo) (bool, error) {
- var pref database.EmailPreference
- if err := c.DB.Where("user_id = ?", info.userID).First(&pref).Error; err != nil {
- return false, errors.Wrap(err, "getting email preference")
- }
-
- if !pref.InactiveReminder {
- return false, nil
- }
-
- var notif database.Notification
- conn := c.DB.Where("user_id = ? AND type = ?", info.userID, mailer.EmailTypeInactiveReminder).Order("created_at DESC").First(¬if)
-
- if conn.RecordNotFound() {
- return true, nil
- } else if err := conn.Error; err != nil {
- return false, errors.Wrap(err, "checking cooldown")
- }
-
- t := c.Clock.Now().AddDate(0, 0, -14)
- if notif.CreatedAt.Before(t) {
- return true, nil
- }
-
- return false, nil
-}
-
-func (c *Context) process(info inactiveUserInfo) error {
- ok, err := c.canNotify(info)
- if err != nil {
- return errors.Wrap(err, "checking if user can be notified")
- }
- if !ok {
- return nil
- }
-
- sender, err := app.GetSenderEmail(c.Config, "noreply@getdnote.com")
- if err != nil {
- return errors.Wrap(err, "getting sender email")
- }
-
- tok, err := mailer.GetToken(c.DB, info.userID, database.TokenTypeEmailPreference)
- if err != nil {
- return errors.Wrap(err, "getting email token")
- }
-
- tmplData := mailer.InactiveReminderTmplData{
- WebURL: c.Config.WebURL,
- SampleNoteUUID: info.sampleNoteUUID,
- Token: tok.Value,
- }
- body, err := c.EmailTmpl.Execute(mailer.EmailTypeInactiveReminder, mailer.EmailKindText, tmplData)
- if err != nil {
- return errors.Wrap(err, "executing inactive email template")
- }
-
- if err := c.EmailBackend.Queue("Your Dnote stopped growing", sender, []string{info.email}, mailer.EmailKindText, body); err != nil {
- return errors.Wrap(err, "queueing email")
- }
-
- if err := c.DB.Create(&database.Notification{
- Type: mailer.EmailTypeInactiveReminder,
- UserID: info.userID,
- }).Error; err != nil {
- return errors.Wrap(err, "creating notification")
- }
-
- return nil
-}
-
-// Result holds the result of the job
-type Result struct {
- SuccessCount int
- FailedUserIDs []int
-}
-
-// DoInactive sends reminder for users with no recent notes
-func DoInactive(c Context) (Result, error) {
- log.Info("performing reminder for no recent notes")
-
- result := Result{}
- items, err := c.getInactiveUserInfo()
- if err != nil {
- return result, errors.Wrap(err, "getting inactive user information")
- }
-
- log.WithFields(log.Fields{
- "user_count": len(items),
- }).Info("counted inactive users")
-
- for _, item := range items {
- err := c.process(item)
-
- if err == nil {
- result.SuccessCount = result.SuccessCount + 1
- } else {
- log.WithFields(log.Fields{
- "user_id": item.userID,
- }).ErrorWrap(err, "Could not process no recent notes reminder")
-
- result.FailedUserIDs = append(result.FailedUserIDs, item.userID)
- }
- }
-
- return result, nil
-}
diff --git a/pkg/server/job/remind/inactive_test.go b/pkg/server/job/remind/inactive_test.go
deleted file mode 100644
index d79095c5..00000000
--- a/pkg/server/job/remind/inactive_test.go
+++ /dev/null
@@ -1,194 +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 .
- */
-
-package remind
-
-import (
- "os"
- "sort"
- "testing"
- "time"
-
- "github.com/dnote/dnote/pkg/assert"
- "github.com/dnote/dnote/pkg/clock"
- "github.com/dnote/dnote/pkg/server/database"
- "github.com/dnote/dnote/pkg/server/mailer"
- "github.com/dnote/dnote/pkg/server/testutils"
- "github.com/pkg/errors"
-)
-
-func getTestContext(c clock.Clock, be *testutils.MockEmailbackendImplementation) Context {
- emailTmplDir := os.Getenv("DNOTE_TEST_EMAIL_TEMPLATE_DIR")
-
- con := Context{
- DB: testutils.DB,
- Clock: c,
- EmailTmpl: mailer.NewTemplates(&emailTmplDir),
- EmailBackend: be,
- }
-
- return con
-}
-
-func TestDoInactive(t *testing.T) {
- defer testutils.ClearData(testutils.DB)
-
- t1 := time.Now()
-
- // u1 is an active user
- u1 := testutils.SetupUserData()
- a1 := testutils.SetupAccountData(u1, "alice@example.com", "pass1234")
- testutils.MustExec(t, testutils.DB.Model(&a1).Update("email_verified", true), "setting email verified")
- testutils.MustExec(t, testutils.DB.Save(&database.EmailPreference{UserID: u1.ID, InactiveReminder: true}), "preparing email preference")
-
- b1 := database.Book{
- UserID: u1.ID,
- Label: "js",
- }
- testutils.MustExec(t, testutils.DB.Save(&b1), "preparing b1")
- n1 := database.Note{
- BookUUID: b1.UUID,
- UserID: u1.ID,
- }
- testutils.MustExec(t, testutils.DB.Save(&n1), "preparing n1")
-
- // u2 is an inactive user
- u2 := testutils.SetupUserData()
- a2 := testutils.SetupAccountData(u2, "bob@example.com", "pass1234")
- testutils.MustExec(t, testutils.DB.Model(&a2).Update("email_verified", true), "setting email verified")
- testutils.MustExec(t, testutils.DB.Save(&database.EmailPreference{UserID: u2.ID, InactiveReminder: true}), "preparing email preference")
-
- b2 := database.Book{
- UserID: u2.ID,
- Label: "css",
- }
- testutils.MustExec(t, testutils.DB.Save(&b2), "preparing b2")
- n2 := database.Note{
- UserID: u2.ID,
- BookUUID: b2.UUID,
- }
- testutils.MustExec(t, testutils.DB.Save(&n2), "preparing n2")
- testutils.MustExec(t, testutils.DB.Model(&n2).Update("created_at", t1.AddDate(0, 0, -15)), "preparing n2")
-
- // u3 is an inactive user with inactive alert email preference disabled
- u3 := testutils.SetupUserData()
- a3 := testutils.SetupAccountData(u3, "alice@example.com", "pass1234")
- testutils.MustExec(t, testutils.DB.Model(&a3).Update("email_verified", true), "setting email verified")
- emailPref3 := database.EmailPreference{UserID: u3.ID}
- testutils.MustExec(t, testutils.DB.Save(&emailPref3), "preparing email preference")
- testutils.MustExec(t, testutils.DB.Model(&emailPref3).Update(map[string]interface{}{"inactive_reminder": false}), "updating email preference")
-
- b3 := database.Book{
- UserID: u3.ID,
- Label: "js",
- }
- testutils.MustExec(t, testutils.DB.Save(&b3), "preparing b3")
- n3 := database.Note{
- BookUUID: b3.UUID,
- UserID: u3.ID,
- }
- testutils.MustExec(t, testutils.DB.Save(&n3), "preparing n3")
- testutils.MustExec(t, testutils.DB.Model(&n3).Update("created_at", t1.AddDate(0, 0, -15)), "preparing n3")
-
- c := clock.NewMock()
- c.SetNow(t1)
- be := &testutils.MockEmailbackendImplementation{}
-
- con := getTestContext(c, be)
- if _, err := DoInactive(con); err != nil {
- t.Fatal(errors.Wrap(err, "performing"))
- }
-
- assert.Equalf(t, len(be.Emails), 1, "email queue count mismatch")
- assert.DeepEqual(t, be.Emails[0].To, []string{a2.Email.String}, "email address mismatch")
-}
-
-func TestDoInactive_Cooldown(t *testing.T) {
- defer testutils.ClearData(testutils.DB)
-
- // setup sets up an inactive user
- setup := func(t *testing.T, now time.Time, email string) database.User {
- u := testutils.SetupUserData()
- a := testutils.SetupAccountData(u, email, "pass1234")
- testutils.MustExec(t, testutils.DB.Model(&a).Update("email_verified", true), "setting email verified")
- testutils.MustExec(t, testutils.DB.Save(&database.EmailPreference{UserID: u.ID, InactiveReminder: true}), "preparing email preference")
-
- b := database.Book{
- UserID: u.ID,
- Label: "css",
- }
- testutils.MustExec(t, testutils.DB.Save(&b), "preparing book")
- n := database.Note{
- UserID: u.ID,
- BookUUID: b.UUID,
- }
- testutils.MustExec(t, testutils.DB.Save(&n), "preparing note")
- testutils.MustExec(t, testutils.DB.Model(&n).Update("created_at", now.AddDate(0, 0, -15)), "preparing note")
-
- return u
- }
-
- // Set up
- now := time.Now()
-
- setup(t, now, "alice@example.com")
-
- bob := setup(t, now, "bob@example.com")
- bobNotif := database.Notification{Type: mailer.EmailTypeInactiveReminder, UserID: bob.ID}
- testutils.MustExec(t, testutils.DB.Create(&bobNotif), "preparing inactive notification for bob")
- testutils.MustExec(t, testutils.DB.Model(&bobNotif).Update("created_at", now.AddDate(0, 0, -7)), "preparing created_at for inactive notification for bob")
-
- chuck := setup(t, now, "chuck@example.com")
- chuckNotif := database.Notification{Type: mailer.EmailTypeInactiveReminder, UserID: chuck.ID}
- testutils.MustExec(t, testutils.DB.Create(&chuckNotif), "preparing inactive notification for chuck")
- testutils.MustExec(t, testutils.DB.Model(&chuckNotif).Update("created_at", now.AddDate(0, 0, -15)), "preparing created_at for inactive notification for chuck")
-
- dan := setup(t, now, "dan@example.com")
- danNotif1 := database.Notification{Type: mailer.EmailTypeInactiveReminder, UserID: dan.ID}
- testutils.MustExec(t, testutils.DB.Create(&danNotif1), "preparing inactive notification 1 for dan")
- testutils.MustExec(t, testutils.DB.Model(&danNotif1).Update("created_at", now.AddDate(0, 0, -10)), "preparing created_at for inactive notification for dan")
- danNotif2 := database.Notification{Type: mailer.EmailTypeInactiveReminder, UserID: dan.ID}
- testutils.MustExec(t, testutils.DB.Create(&danNotif2), "preparing inactive notification 2 for dan")
- testutils.MustExec(t, testutils.DB.Model(&danNotif2).Update("created_at", now.AddDate(0, 0, -15)), "preparing created_at for inactive notification for dan")
-
- c := clock.NewMock()
- c.SetNow(now)
- be := &testutils.MockEmailbackendImplementation{}
-
- // Execute
- con := getTestContext(c, be)
- if _, err := DoInactive(con); err != nil {
- t.Fatal(errors.Wrap(err, "performing"))
- }
-
- // Test
- assert.Equalf(t, len(be.Emails), 2, "email queue count mismatch")
-
- var recipients []string
- for _, email := range be.Emails {
- recipients = append(recipients, email.To[0])
- }
- sort.SliceStable(recipients, func(i, j int) bool {
- r1 := recipients[i]
- r2 := recipients[j]
-
- return r1 < r2
- })
-
- assert.DeepEqual(t, recipients, []string{"alice@example.com", "chuck@example.com"}, "email address mismatch")
-}
diff --git a/pkg/server/mailer/templates/src/reset_password.txt b/pkg/server/mailer/templates/src/reset_password.txt
index a66c0ba8..3bc34850 100644
--- a/pkg/server/mailer/templates/src/reset_password.txt
+++ b/pkg/server/mailer/templates/src/reset_password.txt
@@ -6,4 +6,4 @@ Please click on the following link, or paste this into your browser to complete
You can reply to this message, if you have questions.
-- Sung (Maker of Dnote)
+- Dnote team
diff --git a/pkg/server/mailer/templates/src/reset_password_alert.txt b/pkg/server/mailer/templates/src/reset_password_alert.txt
index f1d7389b..3aa9bdd6 100644
--- a/pkg/server/mailer/templates/src/reset_password_alert.txt
+++ b/pkg/server/mailer/templates/src/reset_password_alert.txt
@@ -6,4 +6,4 @@ If you did not initiate this password change, please notify us by replying, and
Thanks.
-- Sung (Maker of Dnote)
+- Dnote team
diff --git a/pkg/server/mailer/templates/src/subscription_confirmation.txt b/pkg/server/mailer/templates/src/subscription_confirmation.txt
index 03e98a66..e212211f 100644
--- a/pkg/server/mailer/templates/src/subscription_confirmation.txt
+++ b/pkg/server/mailer/templates/src/subscription_confirmation.txt
@@ -3,10 +3,10 @@ Hi, thanks for signing up for Dnote Pro.
Now you can take your notes with you wherever you go!
* Synchronize data among an unlimited number of machines.
-* Access notes anywhere via the web interface.
+* Manage notes via REST API.
Your account is "{{ .AccountEmail }}". Log in at {{ .WebURL }}/login
Thank you for using Dnote. Your support makes it possible to develop it for developers around the world.
-- Sung (Maker of Dnote)
+- Dnote team
diff --git a/pkg/server/mailer/templates/src/verify_email.txt b/pkg/server/mailer/templates/src/verify_email.txt
index 8e40bf89..a85ab705 100644
--- a/pkg/server/mailer/templates/src/verify_email.txt
+++ b/pkg/server/mailer/templates/src/verify_email.txt
@@ -1,9 +1,9 @@
Hi.
-Welcome to Dnote! To verify your email so that you can automate spaced reptition, visit the following link:
+Welcome to Dnote! To verify your email, visit the following link:
{{ .WebURL }}/verify-email/{{ .Token }}
-Thanks for using my software.
+Thanks for using Dnote.
-- Sung (Maker of Dnote)
+- Dnote team
diff --git a/pkg/server/mailer/templates/src/welcome.txt b/pkg/server/mailer/templates/src/welcome.txt
index daba304a..7a33207a 100644
--- a/pkg/server/mailer/templates/src/welcome.txt
+++ b/pkg/server/mailer/templates/src/welcome.txt
@@ -13,4 +13,4 @@ Dnote is open source and you can see the source code at https://github.com/dnote
Feel free to reply anytime. Thanks for using Dnote.
-- Sung (Maker of Dnote)
+- Dnote team
diff --git a/pkg/server/main.go b/pkg/server/main.go
index f1721009..cfd33f15 100644
--- a/pkg/server/main.go
+++ b/pkg/server/main.go
@@ -21,73 +21,26 @@ package main
import (
"flag"
"fmt"
+ "io/ioutil"
"log"
"net/http"
"github.com/dnote/dnote/pkg/clock"
- "github.com/dnote/dnote/pkg/server/api"
"github.com/dnote/dnote/pkg/server/app"
+ "github.com/dnote/dnote/pkg/server/buildinfo"
"github.com/dnote/dnote/pkg/server/config"
+ "github.com/dnote/dnote/pkg/server/controllers"
"github.com/dnote/dnote/pkg/server/database"
"github.com/dnote/dnote/pkg/server/job"
"github.com/dnote/dnote/pkg/server/mailer"
- "github.com/dnote/dnote/pkg/server/web"
+ "github.com/dnote/dnote/pkg/server/views"
"github.com/jinzhu/gorm"
- "github.com/gobuffalo/packr/v2"
"github.com/pkg/errors"
)
-var versionTag = "master"
-var port = flag.String("port", "3000", "port to connect to")
-var rootBox *packr.Box
-
-func init() {
- rootBox = packr.New("root", "../../web/public")
-}
-
-func mustFind(box *packr.Box, path string) []byte {
- b, err := rootBox.Find(path)
- if err != nil {
- panic(errors.Wrapf(err, "getting file content for %s", path))
- }
-
- return b
-}
-
-func initWebContext(db *gorm.DB) web.Context {
- staticBox := packr.New("static", "../../web/public/static")
-
- return web.Context{
- DB: db,
- IndexHTML: mustFind(rootBox, "index.html"),
- RobotsTxt: mustFind(rootBox, "robots.txt"),
- ServiceWorkerJs: mustFind(rootBox, "service-worker.js"),
- StaticFileSystem: staticBox,
- }
-}
-
-func initServer(a app.App) (*http.ServeMux, error) {
- apiRouter, err := api.NewRouter(&api.API{App: &a})
- if err != nil {
- return nil, errors.Wrap(err, "initializing router")
- }
-
- webCtx := initWebContext(a.DB)
- webHandlers, err := web.Init(webCtx)
- if err != nil {
- return nil, errors.Wrap(err, "initializing web handlers")
- }
-
- mux := http.NewServeMux()
- mux.Handle("/api/", http.StripPrefix("/api", apiRouter))
- mux.Handle("/static/", webHandlers.GetStatic)
- mux.HandleFunc("/service-worker.js", webHandlers.GetServiceWorker)
- mux.HandleFunc("/robots.txt", webHandlers.GetRobots)
- mux.HandleFunc("/", webHandlers.GetRoot)
-
- return mux, nil
-}
+var pageDir = flag.String("pageDir", "views", "the path to a directory containing page templates")
+var staticDir = flag.String("staticDir", "./static/", "the path to the static directory ")
func initDB(c config.Config) *gorm.DB {
db, err := gorm.Open("postgres", c.DB.GetConnectionStr())
@@ -99,15 +52,28 @@ func initDB(c config.Config) *gorm.DB {
return db
}
-func initApp(c config.Config) app.App {
- db := initDB(c)
+func mustReadFile(path string) []byte {
+ ret, err := ioutil.ReadFile(path)
+ if err != nil {
+ panic(errors.Wrap(err, "reading file"))
+ }
+
+ return ret
+}
+
+func initApp(cfg config.Config) app.App {
+ db := initDB(cfg)
+
+ files := map[string][]byte{}
+ files[views.ServerErrorPageFileKey] = mustReadFile(fmt.Sprintf("%s/500.html", cfg.StaticDir))
return app.App{
DB: db,
Clock: clock.New(),
EmailTemplates: mailer.NewTemplates(nil),
EmailBackend: &mailer.SimpleBackendImplementation{},
- Config: c,
+ Config: cfg,
+ Files: files,
}
}
@@ -124,34 +90,43 @@ func runJob(a app.App) error {
}
func startCmd() {
- c := config.Load()
+ cfg := config.Load()
+ cfg.SetPageTemplateDir(*pageDir)
+ cfg.SetStaticDir(*staticDir)
+ cfg.SetAssetBaseURL("/static")
- app := initApp(c)
+ app := initApp(cfg)
defer app.DB.Close()
if err := database.Migrate(app.DB); err != nil {
panic(errors.Wrap(err, "running migrations"))
}
-
if err := runJob(app); err != nil {
panic(errors.Wrap(err, "running job"))
}
- srv, err := initServer(app)
- if err != nil {
- panic(errors.Wrap(err, "initializing server"))
+ ctl := controllers.New(&app, *pageDir)
+ rc := controllers.RouteConfig{
+ WebRoutes: controllers.NewWebRoutes(&app, ctl),
+ APIRoutes: controllers.NewAPIRoutes(&app, ctl),
+ Controllers: ctl,
}
- log.Printf("Dnote version %s is running on port %s", versionTag, *port)
- log.Fatalln(http.ListenAndServe(":"+*port, srv))
+ r, err := controllers.NewRouter(&app, rc)
+ if err != nil {
+ panic(errors.Wrap(err, "initializing router"))
+ }
+
+ log.Printf("Dnote version %s is running on port %s", buildinfo.Version, cfg.Port)
+ log.Fatalln(http.ListenAndServe(fmt.Sprintf(":%s", cfg.Port), r))
}
func versionCmd() {
- fmt.Printf("dnote-server-%s\n", versionTag)
+ fmt.Printf("dnote-server-%s\n", buildinfo.Version)
}
func rootCmd() {
- fmt.Printf(`Dnote Server - A simple personal knowledge base
+ fmt.Printf(`Dnote server - a simple personal knowledge base
Usage:
dnote-server [command]
diff --git a/pkg/server/handlers/auth.go b/pkg/server/middleware/auth.go
similarity index 76%
rename from pkg/server/handlers/auth.go
rename to pkg/server/middleware/auth.go
index 83946dce..79d4d780 100644
--- a/pkg/server/handlers/auth.go
+++ b/pkg/server/middleware/auth.go
@@ -16,15 +16,16 @@
* along with Dnote. If not, see .
*/
-package handlers
+package middleware
import (
- "context"
"net/http"
+ "net/url"
"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/helpers"
"github.com/dnote/dnote/pkg/server/log"
@@ -82,11 +83,18 @@ type AuthParams struct {
// Auth is an authentication middleware
func Auth(a *app.App, next http.HandlerFunc, p *AuthParams) http.HandlerFunc {
+ next = WithAccount(a, next)
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- user, ok, err := AuthWithSession(a.DB, r, p)
+ user, ok, err := AuthWithSession(a.DB, r)
if !ok {
if p != nil && p.RedirectGuestsToLogin {
- http.Redirect(w, r, "/login", http.StatusFound)
+
+ q := url.Values{}
+ q.Set("referrer", r.URL.Path)
+ path := helpers.GetPath("/login", &q)
+
+ http.Redirect(w, r, path, http.StatusFound)
return
}
@@ -105,7 +113,24 @@ func Auth(a *app.App, next http.HandlerFunc, p *AuthParams) http.HandlerFunc {
}
}
- ctx := context.WithValue(r.Context(), helpers.KeyUser, user)
+ ctx := context.WithUser(r.Context(), &user)
+ next.ServeHTTP(w, r.WithContext(ctx))
+ })
+
+}
+
+func WithAccount(a *app.App, next http.HandlerFunc) http.HandlerFunc {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ user := context.User(r.Context())
+
+ var account database.Account
+ if err := a.DB.Where("user_id = ?", user.ID).First(&account).Error; err != nil {
+ DoError(w, "finding account", err, http.StatusInternalServerError)
+ return
+ }
+
+ ctx := context.WithAccount(r.Context(), &account)
+
next.ServeHTTP(w, r.WithContext(ctx))
})
}
@@ -122,10 +147,10 @@ func TokenAuth(a *app.App, next http.HandlerFunc, tokenType string, p *AuthParam
ctx := r.Context()
if ok {
- ctx = context.WithValue(ctx, helpers.KeyToken, token)
+ ctx = context.WithToken(ctx, &token)
} else {
// If token-based auth fails, fall back to session-based auth
- user, ok, err = AuthWithSession(a.DB, r, p)
+ user, ok, err = AuthWithSession(a.DB, r)
if err != nil {
DoError(w, "authenticating with session", err, http.StatusInternalServerError)
return
@@ -144,13 +169,13 @@ func TokenAuth(a *app.App, next http.HandlerFunc, tokenType string, p *AuthParam
}
}
- ctx = context.WithValue(ctx, helpers.KeyUser, user)
+ ctx = context.WithUser(ctx, &user)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// AuthWithSession performs user authentication with session
-func AuthWithSession(db *gorm.DB, r *http.Request, p *AuthParams) (database.User, bool, error) {
+func AuthWithSession(db *gorm.DB, r *http.Request) (database.User, bool, error) {
var user database.User
sessionKey, err := GetCredential(r)
@@ -184,3 +209,19 @@ func AuthWithSession(db *gorm.DB, r *http.Request, p *AuthParams) (database.User
return user, true, nil
}
+
+func GuestOnly(a *app.App, next http.HandlerFunc) http.HandlerFunc {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, ok, err := AuthWithSession(a.DB, r)
+ if err != nil {
+ // log the error and continue
+ log.ErrorWrap(err, "authenticating with session")
+ }
+
+ if ok {
+ http.Redirect(w, r, "/", http.StatusFound)
+ } else {
+ next.ServeHTTP(w, r)
+ }
+ })
+}
diff --git a/pkg/server/handlers/helpers.go b/pkg/server/middleware/helpers.go
similarity index 91%
rename from pkg/server/handlers/helpers.go
rename to pkg/server/middleware/helpers.go
index 8c0d51a7..f958d573 100644
--- a/pkg/server/handlers/helpers.go
+++ b/pkg/server/middleware/helpers.go
@@ -16,10 +16,9 @@
* along with Dnote. If not, see .
*/
-package handlers
+package middleware
import (
- "encoding/json"
"net/http"
"strings"
"time"
@@ -90,16 +89,6 @@ func DoError(w http.ResponseWriter, msg string, err error, statusCode int) {
http.Error(w, statusText, statusCode)
}
-// RespondJSON encodes the given payload into a JSON format and writes it to the given response writer
-func RespondJSON(w http.ResponseWriter, statusCode int, payload interface{}) {
- w.Header().Set("Content-Type", "application/json")
- w.WriteHeader(statusCode)
-
- if err := json.NewEncoder(w).Encode(payload); err != nil {
- DoError(w, "encoding response", err, http.StatusInternalServerError)
- }
-}
-
// NotSupported is the handler for the route that is no longer supported
func NotSupported(w http.ResponseWriter, r *http.Request) {
http.Error(w, "API version is not supported. Please upgrade your client.", http.StatusGone)
diff --git a/pkg/server/handlers/helpers_test.go b/pkg/server/middleware/helpers_test.go
similarity index 98%
rename from pkg/server/handlers/helpers_test.go
rename to pkg/server/middleware/helpers_test.go
index f82e6ea0..1eb9fe7a 100644
--- a/pkg/server/handlers/helpers_test.go
+++ b/pkg/server/middleware/helpers_test.go
@@ -16,7 +16,7 @@
* along with Dnote. If not, see .
*/
-package handlers
+package middleware
import (
"fmt"
@@ -184,6 +184,8 @@ func TestAuthMiddleware(t *testing.T) {
defer testutils.ClearData(testutils.DB)
user := testutils.SetupUserData()
+ testutils.SetupAccountData(user, "alice@test.com", "pass1234")
+
session := database.Session{
Key: "A9xgggqzTHETy++GDi1NpDNe0iyqosPm9bitdeNGkJU=",
UserID: user.ID,
@@ -405,13 +407,15 @@ func TestAuthMiddleware_RedirectGuestsToLogin(t *testing.T) {
// test
assert.Equal(t, res.StatusCode, http.StatusFound, "status code mismatch")
- assert.Equal(t, res.Header.Get("Location"), "/login", "location header mismatch")
+ assert.Equal(t, res.Header.Get("Location"), "/login?referrer=%2F", "location header mismatch")
})
t.Run("logged in user", func(t *testing.T) {
req := testutils.MakeReq(server.URL, "GET", "/", "")
user := testutils.SetupUserData()
+ testutils.SetupAccountData(user, "alice@test.com", "pass1234")
+
testutils.MustExec(t, testutils.DB.Model(&user).Update("cloud", false), "preparing session")
session := database.Session{
Key: "A9xgggqzTHETy++GDi1NpDNe0iyqosPm9bitdeNGkJU=",
diff --git a/pkg/server/handlers/limit.go b/pkg/server/middleware/limit.go
similarity index 92%
rename from pkg/server/handlers/limit.go
rename to pkg/server/middleware/limit.go
index 9859e7e2..bdc5de40 100644
--- a/pkg/server/handlers/limit.go
+++ b/pkg/server/middleware/limit.go
@@ -16,10 +16,11 @@
* along with Dnote. If not, see .
*/
-package handlers
+package middleware
import (
"net/http"
+ "os"
"strings"
"sync"
"time"
@@ -122,3 +123,14 @@ func Limit(next http.Handler) http.HandlerFunc {
next.ServeHTTP(w, r)
})
}
+
+// ApplyLimit applies rate limit conditionally
+func ApplyLimit(h http.HandlerFunc, rateLimit bool) http.Handler {
+ ret := h
+
+ if rateLimit && os.Getenv("GO_ENV") != "TEST" {
+ ret = Limit(ret)
+ }
+
+ return ret
+}
diff --git a/pkg/server/handlers/logging.go b/pkg/server/middleware/logging.go
similarity index 97%
rename from pkg/server/handlers/logging.go
rename to pkg/server/middleware/logging.go
index 98e36597..85bfbf32 100644
--- a/pkg/server/handlers/logging.go
+++ b/pkg/server/middleware/logging.go
@@ -16,7 +16,7 @@
* along with Dnote. If not, see .
*/
-package handlers
+package middleware
import (
"fmt"
@@ -39,7 +39,7 @@ func (w *logResponseWriter) WriteHeader(code int) {
w.ResponseWriter.WriteHeader(code)
}
-// Logging is a logging middleware
+// logging is a logging middleware
func Logging(inner http.Handler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
diff --git a/pkg/server/api/main_test.go b/pkg/server/middleware/main_test.go
similarity index 98%
rename from pkg/server/api/main_test.go
rename to pkg/server/middleware/main_test.go
index cca6ae29..08a3acf1 100644
--- a/pkg/server/api/main_test.go
+++ b/pkg/server/middleware/main_test.go
@@ -16,7 +16,7 @@
* along with Dnote. If not, see .
*/
-package api
+package middleware
import (
"os"
diff --git a/pkg/server/middleware/middleware.go b/pkg/server/middleware/middleware.go
new file mode 100644
index 00000000..44f6377a
--- /dev/null
+++ b/pkg/server/middleware/middleware.go
@@ -0,0 +1,76 @@
+package middleware
+
+import (
+ "net/http"
+ "net/url"
+
+ "github.com/dnote/dnote/pkg/server/app"
+ "github.com/gorilla/schema"
+)
+
+// Middleware is a middleware for request handlers
+type Middleware func(h http.Handler, app *app.App, rateLimit bool) http.Handler
+
+type payload struct {
+ Method string `schema:"_method"`
+}
+
+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
+}
+
+// methodOverrideKey is the form key for overriding the method
+var methodOverrideKey = "_method"
+
+// methodOverride overrides the request's method to simulate form actions that
+// are not natively supported by web browsers
+func methodOverride(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodPost {
+ method := r.PostFormValue(methodOverrideKey)
+
+ if method == http.MethodPut || method == http.MethodPatch || method == http.MethodDelete {
+ r.Method = method
+ }
+ }
+
+ next.ServeHTTP(w, r)
+ })
+}
+
+// WebMw is the middleware for the web
+func WebMw(h http.Handler, app *app.App, rateLimit bool) http.Handler {
+ ret := h
+
+ ret = ApplyLimit(ret.ServeHTTP, rateLimit)
+
+ return ret
+}
+
+// APIMw is the middleware for the API
+func APIMw(h http.Handler, app *app.App, rateLimit bool) http.Handler {
+ ret := h
+
+ ret = ApplyLimit(ret.ServeHTTP, rateLimit)
+
+ return ret
+}
+
+// Global is the middleware for all routes
+func Global(h http.Handler) http.Handler {
+ ret := h
+
+ ret = Logging(ret)
+ ret = methodOverride(ret)
+
+ return ret
+}
diff --git a/pkg/server/net/writer.go b/pkg/server/net/writer.go
new file mode 100644
index 00000000..912d44d4
--- /dev/null
+++ b/pkg/server/net/writer.go
@@ -0,0 +1,31 @@
+package net
+
+import (
+ "github.com/dnote/dnote/pkg/server/log"
+ "net/http"
+)
+
+// LifecycleWriter wraps http.ResponseWriter to track state of the http response.
+// The optional interfaces of http.ResponseWriter are lost because of the wrapping, and
+// such interfaces should be implemented if needed. (i.e. http.Pusher, http.Flusher, etc.)
+type LifecycleWriter struct {
+ http.ResponseWriter
+ StatusCode int
+}
+
+// WriteHeader wraps the WriteHeader call and marks the response state as done.
+func (w *LifecycleWriter) WriteHeader(code int) {
+ w.StatusCode = code
+ w.ResponseWriter.WriteHeader(code)
+}
+
+// IsHeaderWritten returns true if a response has been written.
+func IsHeaderWritten(w http.ResponseWriter) bool {
+ if lw, ok := w.(*LifecycleWriter); ok {
+ return lw.StatusCode != 0
+ }
+
+ // the response writer must have been wrapped in the middleware chain.
+ log.Error("unable to log because writer is not a LifecycleWriter")
+ return false
+}
diff --git a/pkg/server/operations/notes.go b/pkg/server/operations/notes.go
index 22bb8468..1106ab5e 100644
--- a/pkg/server/operations/notes.go
+++ b/pkg/server/operations/notes.go
@@ -27,7 +27,7 @@ import (
)
// GetNote retrieves a note for the given user
-func GetNote(db *gorm.DB, uuid string, user database.User) (database.Note, bool, error) {
+func GetNote(db *gorm.DB, uuid string, user *database.User) (database.Note, bool, error) {
zeroNote := database.Note{}
if !helpers.ValidateUUID(uuid) {
return zeroNote, false, nil
@@ -45,7 +45,7 @@ func GetNote(db *gorm.DB, uuid string, user database.User) (database.Note, bool,
return zeroNote, false, errors.Wrap(err, "finding note")
}
- if ok := permissions.ViewNote(&user, note); !ok {
+ if ok := permissions.ViewNote(user, note); !ok {
return zeroNote, false, nil
}
diff --git a/pkg/server/operations/notes_test.go b/pkg/server/operations/notes_test.go
index 0ea93878..ceb0728f 100644
--- a/pkg/server/operations/notes_test.go
+++ b/pkg/server/operations/notes_test.go
@@ -107,7 +107,7 @@ func TestGetNote(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
- note, ok, err := GetNote(testutils.DB, tc.note.UUID, tc.user)
+ note, ok, err := GetNote(testutils.DB, tc.note.UUID, &tc.user)
if err != nil {
t.Fatal(errors.Wrap(err, "executing"))
}
@@ -141,7 +141,7 @@ func TestGetNote_nonexistent(t *testing.T) {
testutils.MustExec(t, testutils.DB.Save(&n1), "preparing n1")
nonexistentUUID := "4fd19336-671e-4ff3-8f22-662b80e22edd"
- note, ok, err := GetNote(testutils.DB, nonexistentUUID, user)
+ note, ok, err := GetNote(testutils.DB, nonexistentUUID, &user)
if err != nil {
t.Fatal(errors.Wrap(err, "executing"))
}
diff --git a/pkg/server/static/main.css b/pkg/server/static/main.css
new file mode 100644
index 00000000..131ab48d
--- /dev/null
+++ b/pkg/server/static/main.css
@@ -0,0 +1,12 @@
+/*!
+ * 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:.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:.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:rgba(0,0,0,0)}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:.75rem;padding-bottom:.75rem;color:#6c757d;text-align:left;caption-side:bottom}th{text-align:inherit}label{display:inline-block;margin-bottom:.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:.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}/*!
+ * Bootstrap Grid 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)
+ */html{box-sizing:border-box;-ms-overflow-style:scrollbar}*,*::before,*::after{box-sizing:inherit}.container-wide{width:100%;padding-right:15px;padding-left:15px;margin-right:auto;margin-left:auto}@media(min-width: 576px){.container-wide{max-width:540px}}@media(min-width: 768px){.container-wide{max-width:720px}}@media(min-width: 992px){.container-wide{max-width:960px}}@media(min-width: 1200px){.container-wide{max-width:1040px}}@media(min-width: 1440px){.container-wide{max-width:1280px}}@media(min-width: 1800px){.container-wide{max-width:1660px}}.container-fluid{width:100%;padding-right:15px;padding-left:15px;margin-right:auto;margin-left:auto}.container{width:100%;padding-right:15px;padding-left:15px;margin-right:auto;margin-left:auto}@media(min-width: 576px){.container{max-width:540px}}@media(min-width: 768px){.container{max-width:720px}}@media(min-width: 992px){.container{max-width:960px}}@media(min-width: 1200px){.container{max-width:1280px}}.row{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;margin-right:-15px;margin-left:-15px}.no-gutters{margin-right:0;margin-left:0}.no-gutters>.col,.no-gutters>[class*=col-]{padding-right:0;padding-left:0}.col-1,.col-2,.col-3,.col-4,.col-5,.col-6,.col-7,.col-8,.col-9,.col-10,.col-11,.col-12,.col,.col-auto,.col-sm-1,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9,.col-sm-10,.col-sm-11,.col-sm-12,.col-sm,.col-sm-auto,.col-md-1,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9,.col-md-10,.col-md-11,.col-md-12,.col-md,.col-md-auto,.col-lg-1,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9,.col-lg-10,.col-lg-11,.col-lg-12,.col-lg,.col-lg-auto,.col-xl-1,.col-xl-2,.col-xl-3,.col-xl-4,.col-xl-5,.col-xl-6,.col-xl-7,.col-xl-8,.col-xl-9,.col-xl-10,.col-xl-11,.col-xl-12,.col-xl,.col-xl-auto{position:relative;width:100%;padding-right:15px;padding-left:15px}.col{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.col-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-1{-ms-flex:0 0 8.333333%;flex:0 0 8.333333%;max-width:8.333333%}.col-2{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-4{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.col-5{-ms-flex:0 0 41.666667%;flex:0 0 41.666667%;max-width:41.666667%}.col-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-7{-ms-flex:0 0 58.333333%;flex:0 0 58.333333%;max-width:58.333333%}.col-8{-ms-flex:0 0 66.666667%;flex:0 0 66.666667%;max-width:66.666667%}.col-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-10{-ms-flex:0 0 83.333333%;flex:0 0 83.333333%;max-width:83.333333%}.col-11{-ms-flex:0 0 91.666667%;flex:0 0 91.666667%;max-width:91.666667%}.col-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-first{-ms-flex-order:-1;order:-1}.order-last{-ms-flex-order:13;order:13}.order-0{-ms-flex-order:0;order:0}.order-1{-ms-flex-order:1;order:1}.order-2{-ms-flex-order:2;order:2}.order-3{-ms-flex-order:3;order:3}.order-4{-ms-flex-order:4;order:4}.order-5{-ms-flex-order:5;order:5}.order-6{-ms-flex-order:6;order:6}.order-7{-ms-flex-order:7;order:7}.order-8{-ms-flex-order:8;order:8}.order-9{-ms-flex-order:9;order:9}.order-10{-ms-flex-order:10;order:10}.order-11{-ms-flex-order:11;order:11}.order-12{-ms-flex-order:12;order:12}.offset-1{margin-left:8.333333%}.offset-2{margin-left:16.666667%}.offset-3{margin-left:25%}.offset-4{margin-left:33.333333%}.offset-5{margin-left:41.666667%}.offset-6{margin-left:50%}.offset-7{margin-left:58.333333%}.offset-8{margin-left:66.666667%}.offset-9{margin-left:75%}.offset-10{margin-left:83.333333%}.offset-11{margin-left:91.666667%}@media(min-width: 576px){.col-sm{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.col-sm-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-sm-1{-ms-flex:0 0 8.333333%;flex:0 0 8.333333%;max-width:8.333333%}.col-sm-2{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-sm-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-sm-4{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.col-sm-5{-ms-flex:0 0 41.666667%;flex:0 0 41.666667%;max-width:41.666667%}.col-sm-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-sm-7{-ms-flex:0 0 58.333333%;flex:0 0 58.333333%;max-width:58.333333%}.col-sm-8{-ms-flex:0 0 66.666667%;flex:0 0 66.666667%;max-width:66.666667%}.col-sm-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-sm-10{-ms-flex:0 0 83.333333%;flex:0 0 83.333333%;max-width:83.333333%}.col-sm-11{-ms-flex:0 0 91.666667%;flex:0 0 91.666667%;max-width:91.666667%}.col-sm-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-sm-first{-ms-flex-order:-1;order:-1}.order-sm-last{-ms-flex-order:13;order:13}.order-sm-0{-ms-flex-order:0;order:0}.order-sm-1{-ms-flex-order:1;order:1}.order-sm-2{-ms-flex-order:2;order:2}.order-sm-3{-ms-flex-order:3;order:3}.order-sm-4{-ms-flex-order:4;order:4}.order-sm-5{-ms-flex-order:5;order:5}.order-sm-6{-ms-flex-order:6;order:6}.order-sm-7{-ms-flex-order:7;order:7}.order-sm-8{-ms-flex-order:8;order:8}.order-sm-9{-ms-flex-order:9;order:9}.order-sm-10{-ms-flex-order:10;order:10}.order-sm-11{-ms-flex-order:11;order:11}.order-sm-12{-ms-flex-order:12;order:12}.offset-sm-0{margin-left:0}.offset-sm-1{margin-left:8.333333%}.offset-sm-2{margin-left:16.666667%}.offset-sm-3{margin-left:25%}.offset-sm-4{margin-left:33.333333%}.offset-sm-5{margin-left:41.666667%}.offset-sm-6{margin-left:50%}.offset-sm-7{margin-left:58.333333%}.offset-sm-8{margin-left:66.666667%}.offset-sm-9{margin-left:75%}.offset-sm-10{margin-left:83.333333%}.offset-sm-11{margin-left:91.666667%}}@media(min-width: 768px){.col-md{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.col-md-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-md-1{-ms-flex:0 0 8.333333%;flex:0 0 8.333333%;max-width:8.333333%}.col-md-2{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-md-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-md-4{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.col-md-5{-ms-flex:0 0 41.666667%;flex:0 0 41.666667%;max-width:41.666667%}.col-md-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-md-7{-ms-flex:0 0 58.333333%;flex:0 0 58.333333%;max-width:58.333333%}.col-md-8{-ms-flex:0 0 66.666667%;flex:0 0 66.666667%;max-width:66.666667%}.col-md-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-md-10{-ms-flex:0 0 83.333333%;flex:0 0 83.333333%;max-width:83.333333%}.col-md-11{-ms-flex:0 0 91.666667%;flex:0 0 91.666667%;max-width:91.666667%}.col-md-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-md-first{-ms-flex-order:-1;order:-1}.order-md-last{-ms-flex-order:13;order:13}.order-md-0{-ms-flex-order:0;order:0}.order-md-1{-ms-flex-order:1;order:1}.order-md-2{-ms-flex-order:2;order:2}.order-md-3{-ms-flex-order:3;order:3}.order-md-4{-ms-flex-order:4;order:4}.order-md-5{-ms-flex-order:5;order:5}.order-md-6{-ms-flex-order:6;order:6}.order-md-7{-ms-flex-order:7;order:7}.order-md-8{-ms-flex-order:8;order:8}.order-md-9{-ms-flex-order:9;order:9}.order-md-10{-ms-flex-order:10;order:10}.order-md-11{-ms-flex-order:11;order:11}.order-md-12{-ms-flex-order:12;order:12}.offset-md-0{margin-left:0}.offset-md-1{margin-left:8.333333%}.offset-md-2{margin-left:16.666667%}.offset-md-3{margin-left:25%}.offset-md-4{margin-left:33.333333%}.offset-md-5{margin-left:41.666667%}.offset-md-6{margin-left:50%}.offset-md-7{margin-left:58.333333%}.offset-md-8{margin-left:66.666667%}.offset-md-9{margin-left:75%}.offset-md-10{margin-left:83.333333%}.offset-md-11{margin-left:91.666667%}}@media(min-width: 992px){.col-lg{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.col-lg-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-lg-1{-ms-flex:0 0 8.333333%;flex:0 0 8.333333%;max-width:8.333333%}.col-lg-2{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-lg-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-lg-4{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.col-lg-5{-ms-flex:0 0 41.666667%;flex:0 0 41.666667%;max-width:41.666667%}.col-lg-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-lg-7{-ms-flex:0 0 58.333333%;flex:0 0 58.333333%;max-width:58.333333%}.col-lg-8{-ms-flex:0 0 66.666667%;flex:0 0 66.666667%;max-width:66.666667%}.col-lg-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-lg-10{-ms-flex:0 0 83.333333%;flex:0 0 83.333333%;max-width:83.333333%}.col-lg-11{-ms-flex:0 0 91.666667%;flex:0 0 91.666667%;max-width:91.666667%}.col-lg-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-lg-first{-ms-flex-order:-1;order:-1}.order-lg-last{-ms-flex-order:13;order:13}.order-lg-0{-ms-flex-order:0;order:0}.order-lg-1{-ms-flex-order:1;order:1}.order-lg-2{-ms-flex-order:2;order:2}.order-lg-3{-ms-flex-order:3;order:3}.order-lg-4{-ms-flex-order:4;order:4}.order-lg-5{-ms-flex-order:5;order:5}.order-lg-6{-ms-flex-order:6;order:6}.order-lg-7{-ms-flex-order:7;order:7}.order-lg-8{-ms-flex-order:8;order:8}.order-lg-9{-ms-flex-order:9;order:9}.order-lg-10{-ms-flex-order:10;order:10}.order-lg-11{-ms-flex-order:11;order:11}.order-lg-12{-ms-flex-order:12;order:12}.offset-lg-0{margin-left:0}.offset-lg-1{margin-left:8.333333%}.offset-lg-2{margin-left:16.666667%}.offset-lg-3{margin-left:25%}.offset-lg-4{margin-left:33.333333%}.offset-lg-5{margin-left:41.666667%}.offset-lg-6{margin-left:50%}.offset-lg-7{margin-left:58.333333%}.offset-lg-8{margin-left:66.666667%}.offset-lg-9{margin-left:75%}.offset-lg-10{margin-left:83.333333%}.offset-lg-11{margin-left:91.666667%}}@media(min-width: 1200px){.col-xl{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.col-xl-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-xl-1{-ms-flex:0 0 8.333333%;flex:0 0 8.333333%;max-width:8.333333%}.col-xl-2{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-xl-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-xl-4{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.col-xl-5{-ms-flex:0 0 41.666667%;flex:0 0 41.666667%;max-width:41.666667%}.col-xl-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-xl-7{-ms-flex:0 0 58.333333%;flex:0 0 58.333333%;max-width:58.333333%}.col-xl-8{-ms-flex:0 0 66.666667%;flex:0 0 66.666667%;max-width:66.666667%}.col-xl-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-xl-10{-ms-flex:0 0 83.333333%;flex:0 0 83.333333%;max-width:83.333333%}.col-xl-11{-ms-flex:0 0 91.666667%;flex:0 0 91.666667%;max-width:91.666667%}.col-xl-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-xl-first{-ms-flex-order:-1;order:-1}.order-xl-last{-ms-flex-order:13;order:13}.order-xl-0{-ms-flex-order:0;order:0}.order-xl-1{-ms-flex-order:1;order:1}.order-xl-2{-ms-flex-order:2;order:2}.order-xl-3{-ms-flex-order:3;order:3}.order-xl-4{-ms-flex-order:4;order:4}.order-xl-5{-ms-flex-order:5;order:5}.order-xl-6{-ms-flex-order:6;order:6}.order-xl-7{-ms-flex-order:7;order:7}.order-xl-8{-ms-flex-order:8;order:8}.order-xl-9{-ms-flex-order:9;order:9}.order-xl-10{-ms-flex-order:10;order:10}.order-xl-11{-ms-flex-order:11;order:11}.order-xl-12{-ms-flex-order:12;order:12}.offset-xl-0{margin-left:0}.offset-xl-1{margin-left:8.333333%}.offset-xl-2{margin-left:16.666667%}.offset-xl-3{margin-left:25%}.offset-xl-4{margin-left:33.333333%}.offset-xl-5{margin-left:41.666667%}.offset-xl-6{margin-left:50%}.offset-xl-7{margin-left:58.333333%}.offset-xl-8{margin-left:66.666667%}.offset-xl-9{margin-left:75%}.offset-xl-10{margin-left:83.333333%}.offset-xl-11{margin-left:91.666667%}}.form-control{display:block;width:100%;padding:.375rem .75rem;font-size:1rem;line-height:1.5;color:#495057;background-color:#fff;background-clip:padding-box;border:1px solid #ced4da;border-radius:.25rem;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}.alert{position:relative;padding:1.75rem 1.25rem;border:1px solid rgba(0,0,0,0)}.alert-heading{color:inherit}.alert-link{font-weight:700}.alert-dismissible .close{position:absolute;top:0;right:0;padding:.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}.alert-slim{padding:.75rem 1.25rem}:export{mdBreakpoint:576px;smBreakpoint:321px}.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:.2s;transition-timing-function:ease-in-out;text-decoration:none;cursor:pointer}.button:not(.button-no-ui){border-width:1px;border-style:solid;border-color:rgba(0,0,0,0)}.button:not(:disabled):hover{text-decoration:none}.button:disabled{cursor:not-allowed;opacity:.6}.button:focus{outline:2px dotted #9c9c9c}button:disabled{cursor:not-allowed;opacity:.6}.button-small{font-size:14px;font-size:1.4rem;padding:.4rem 1.2rem}@media(min-width: 576px){.button-small{font-size:14px;font-size:1.4rem}}@media(min-width: 992px){.button-small{font-size:14px;font-size:1.4rem}}.button-normal{padding:.8rem 1.6rem}.button-large{font-size:18px;font-size:1.8rem;padding:.8rem 2.4rem}@media(min-width: 576px){.button-large{font-size:18px;font-size:1.8rem}}@media(min-width: 992px){.button-large{font-size:18px;font-size:1.8rem}}@media(min-width: 576px){.button-large{padding:1.2rem 3.6rem}}@media(min-width: 992px){.button-large{padding:1.2rem 4.8rem}}.button-xlarge{font-size:19.2px;font-size:1.92rem;padding:1.6rem 2.4rem}@media(min-width: 576px){.button-xlarge{font-size:21.6px;font-size:2.16rem}}@media(min-width: 992px){.button-xlarge{font-size:24px;font-size:2.4rem}}@media(min-width: 576px){.button-xlarge{padding:1.2rem 3.6rem}}@media(min-width: 992px){.button-xlarge{padding:1.6rem 4.8rem}}.button-first{color:#fff;background-color:#333745}.button-first:not(:disabled):hover{color:#fff;background-color:#282b36;box-shadow:0px 0px 4px 2px #cacaca}.button-first-outline{background:rgba(0,0,0,0);color:#333745}.button-first-outline:not(.button-no-ui){border-color:#333745;border-width:2px}.button-first-outline:not(:disabled):hover{color:#333745;box-shadow:0px 0px 4px 2px #cacaca}.button-second{color:#2a2a2a;background-color:#e7e7e7}.button-second:not(:disabled):hover{color:#2a2a2a;background-color:#dadada;box-shadow:0px 0px 4px 2px #cacaca}.button-second-outline{background:rgba(0,0,0,0);color:#2a2a2a}.button-second-outline:not(.button-no-ui){border-color:#e7e7e7;border-width:2px}.button-second-outline:not(:disabled):hover{color:#2a2a2a;box-shadow:0px 0px 4px 2px #cacaca}.button-third{color:#fff;background-color:#0a4b73}.button-third:not(:disabled):hover{color:#fff;background-color:#083c5c;box-shadow:0px 0px 4px 2px #cacaca}.button-third-outline{background:rgba(0,0,0,0);color:#0a4b73}.button-third-outline:not(.button-no-ui){border-color:#0a4b73;border-width:2px}.button-third-outline:not(:disabled):hover{color:#0a4b73;box-shadow:0px 0px 4px 2px #cacaca}.button-danger{background:rgba(0,0,0,0);color:#cb2431;font-weight:600}.button-danger:not(.button-no-ui){border-color:#cb2431;border-width:2px}.button-danger:not(:disabled):hover{color:#cb2431;box-shadow:0px 0px 4px 2px #cacaca}.button-stretch{width:100%}.button~.button{margin-left:1.2rem}.button-no-ui{border:none;background:none;text-align:left;cursor:pointer}.button-no-padding{padding:0}.button-link{color:#6f53c0}.button-link:hover{color:#6143b7;text-decoration:underline}:export{mdBreakpoint:576px;smBreakpoint:321px}.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:.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:rgba(0,0,0,0) rgba(0,0,0,0) #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 .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: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 rgba(0,0,0,0);border:0 none;box-shadow:none;cursor:default;display:inline-block;font-family:inherit;font-size:inherit;margin:0;outline:none;line-height:17px;padding:8px 0 12px;-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 rgba(0,0,0,0) rgba(0,0,0,0);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,.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:#666;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;background-color:rgba(0,126,255,.04);color:#333}.Select-option.is-focused{background-color:#ebf5ff;background-color:rgba(0,126,255,.08);color:#333}.Select-option.is-disabled{color:#ccc;cursor:default}.Select-noresults{box-sizing:border-box;color:#999;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;background-color:rgba(0,126,255,.08);border-radius:2px;border:1px solid #c2e0ff;border:1px solid rgba(0,126,255,.24);color:#007eff;display:inline-block;font-size:.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;border-right:1px solid rgba(0,126,255,.24);padding:1px 5px 3px}.Select--multi .Select-value-icon:hover,.Select--multi .Select-value-icon:focus{background-color:#d8eafd;background-color:rgba(0,113,230,.08);color:#0071e6}.Select--multi .Select-value-icon:active{background-color:#c2e0ff;background-color:rgba(0,126,255,.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;border-left:1px solid rgba(0,126,255,.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)}}:export{mdBreakpoint:576px;smBreakpoint:321px}:export{mdBreakpoint:576px;smBreakpoint:321px}@keyframes holderPulse{0%{opacity:.4}50%{opacity:1}100%{opacity:.4}}.holder{animation:holderPulse 800ms infinite;background:#f4f4f4}.holder.holder-dark{background:#e6e6e6}input[type=text]:disabled,input[type=email]:disabled,input[type=number]:disabled,input[type=password]:disabled,textarea:disabled{background-color:#f3f3f3;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,button svg{display:block}.text-input{border:1px solid #d8d8d8;padding:.8rem 1.2rem;position:relative;border-radius:.4rem;display:block}.text-input::placeholder{color:#686868}.text-input:focus{border-color:#ecf4ff;box-shadow:inset 0 1px 2px rgba(24,31,35,.075),0 0 0 .2em rgba(4,100,210,.3);outline:none}.text-input-small{padding:.4rem 1.2rem}.text-input-medium{padding:.8rem 1.2rem}.text-input-stretch{width:100%}.label-full{width:100%}a{color:#6f53c0}a:hover{color:#6143b7}h1,h2,h3,h4,h5,h6{margin-bottom:0}@media(max-width: 991px){.container.mobile-fw{max-width:100%}}@media(max-width: 991px){.container.mobile-nopadding{padding-left:0;padding-right:0}.container.mobile-nopadding .row{margin-left:0;margin-right:0}.container.mobile-nopadding [class*=col-]{padding-left:0;padding-right:0}}html body{overflow-y:scroll}.page{padding-top:2rem;padding-bottom:2rem}.page.page-mobile-full{padding-top:0;padding-bottom:0}@media(min-width: 992px){.page.page-mobile-full{padding-top:3.2rem;padding-bottom:3.2rem}}.page-header{margin-top:2rem}.page-header.page-header-full{margin-bottom:2rem}@media(min-width: 992px){.page-header{margin-bottom:2rem;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 #d8d8d8;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,.08)}.form-select:focus{border-color:#2188ff;outline:none;box-shadow:inset 0 1px 2px rgba(32,36,41,.08),0 0 0 2px rgba(3,102,214,.3)}.form-select:disabled,.form-select.form-select-disabled{background-image:url("data:image/png;base64, iVBORw0KGgoAAAANSUhEUgAAABAAAAAUCAYAAACEYr13AAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAEKSURBVHgBzVTNDYIwFC4NB46OwAi4gY7gETgoE6gTGCcwTgAJ4efGCLCBjMAIXrmA3yOhQazQhJj4JQ0v7fte3/e1hbFfIk3TYxzHp6kc7dtCFEUW5/xBcdM0a9d1S1kel00mSWKCnIkkxDSnXADIMYYEU9O0zPf91WwB6L6NyB3atrUMw7hNFkCbFyROmXYYmypMDMNwo+t6ztSwtW27oEAXrXBuwu2rCht+WPgU7C8gPCBzYOBKhQS5FTwIKBYeQFeJoWyiKNYH5Co6OCuQr/0JdBuPVyElQCd7GRMb3B3HebsHHzexrmvyQvZwqjFZWsDzvCc62BFhSGYD3UMsfs6ToKOd+6EsxgtrtWLW4gUN3AAAAABJRU5ErkJggg==");background-color:#f3f3f3}.input-label{width:auto;font-weight:600;margin-bottom:.4rem;font-size:14px;font-size:1.4rem}@media(min-width: 576px){.input-label{font-size:14px;font-size:1.4rem}}@media(min-width: 992px){.input-label{font-size:14px;font-size:1.4rem}}.page-heading{font-size:19.2px;font-size:1.92rem}@media(min-width: 576px){.page-heading{font-size:21.6px;font-size:2.16rem}}@media(min-width: 992px){.page-heading{font-size:24px;font-size:2.4rem}}.dropdown-caret{display:inline-block;vertical-align:middle;border-top-width:4px;border-top-style:solid;border-right:4px solid rgba(0,0,0,0);border-bottom:0 solid rgba(0,0,0,0);border-left:4px solid rgba(0,0,0,0);margin-left:.8rem}.divider{height:0;overflow:hidden;border-top:1px solid #e9ecef}.marker{display:inline-block;padding:.25em .4em;font-size:75%;font-weight:700;line-height:1;text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:.25rem}.marker-first{color:#fff;background-color:#007bff}.marker-info{color:#fff;background-color:#17a2b8}.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:rgba(0,0,0,0)}.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:.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:rgba(0,0,0,0);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:.25em;margin:24px 0;padding:0}.markdown-body blockquote{border-left:.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:.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:.875em}.markdown-body h6{color:#6a737d;font-size:.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:.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,.05);border-radius:3px;font-size:85%;margin:0;padding:.2em .4em}.markdown-body pre{word-wrap:normal}.markdown-body pre>code{background:rgba(0,0,0,0);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:rgba(0,0,0,0);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,.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,.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 .2em .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}.hljs{display:block;overflow-x:auto;padding:.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:teal}.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:navy;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}:export{mdBreakpoint:576px;smBreakpoint:321px}.auth-page{background:#f3f3f3;text-align:center;min-height:100vh;padding:50px 0}.auth-page .auth-button{margin-top:8px}.auth-page .heading{color:#2a2a2a;font-size:25.6px;font-size:2.56rem;font-weight:300;margin-top:12px;margin-bottom:0}@media(min-width: 576px){.auth-page .heading{font-size:28.8px;font-size:2.88rem}}@media(min-width: 992px){.auth-page .heading{font-size:32px;font-size:3.2rem}}.auth-page .body{max-width:420px;margin-left:auto;margin-right:auto;margin-top:20px}.auth-page .referrer-flash{margin:24px 0}.auth-page .error-flash{margin-bottom:24px}.auth-page .footer{margin-top:20px;line-height:20px}.auth-page .callout{color:#7c7c7c;font-size:14px;font-size:1.4rem}@media(min-width: 576px){.auth-page .callout{font-size:14px;font-size:1.4rem}}@media(min-width: 992px){.auth-page .callout{font-size:14px;font-size:1.4rem}}.auth-page .cta{font-size:14px;font-size:1.4rem}@media(min-width: 576px){.auth-page .cta{font-size:14px;font-size:1.4rem}}@media(min-width: 992px){.auth-page .cta{font-size:14px;font-size:1.4rem}}.auth-page .panel{border:1px solid #d8d8d8;background:#fff;border-radius:2px;padding:20px;text-align:left}.auth-page .auth-button{margin-top:16px}.auth-page .input-row~.input-row{margin-top:12px}.auth-page .label{font-size:14px;font-size:1.4rem;font-weight:600;width:100%;margin-bottom:0}@media(min-width: 576px){.auth-page .label{font-size:14px;font-size:1.4rem}}@media(min-width: 992px){.auth-page .label{font-size:14px;font-size:1.4rem}}.auth-page .forgot{font-size:14px;font-size:1.4rem;float:right;font-weight:400}@media(min-width: 576px){.auth-page .forgot{font-size:14px;font-size:1.4rem}}@media(min-width: 992px){.auth-page .forgot{font-size:14px;font-size:1.4rem}}.auth-page.password-reset-page .email-input{margin-top:1.6rem}.auth-page .alert{margin-bottom:1rem}:export{mdBreakpoint:576px;smBreakpoint:321px}.home-page .note-group-list{flex-grow:1}@media(min-width: 992px){.home-page .note-group-list{margin-top:1.6rem}}.home-page .note-group-list .note-group-list-empty{padding:4rem 1.6rem;text-align:center;color:#686868}.home-page .note-group{position:relative;border-radius:4px;box-shadow:0 0 8px rgba(0,0,0,.14)}.home-page .note-group:not(:first-of-type){margin-top:2rem}@media(min-width: 576px){.home-page .note-group:not(:first-of-type){margin-top:2.4rem}}.home-page .note-group .note-group-header{font-size:14px;font-size:1.4rem;display:flex;justify-content:space-between;color:#fff;padding:1.2rem 1.6rem;background:#f7f9fa;color:#2a2a2a;border-bottom:1px solid #d8d8d8;border-top-left-radius:4px;border-top-right-radius:4px}@media(min-width: 576px){.home-page .note-group .note-group-header{font-size:14px;font-size:1.4rem}}@media(min-width: 992px){.home-page .note-group .note-group-header{font-size:14px;font-size:1.4rem}}.home-page .note-group .date{font-weight:600;font-size:14px;font-size:1.4rem}@media(min-width: 576px){.home-page .note-group .date{font-size:14px;font-size:1.4rem}}@media(min-width: 992px){.home-page .note-group .date{font-size:14px;font-size:1.4rem}}.home-page .note-group .mask{position:absolute;top:0;bottom:0;left:0;right:0;background:#fff;z-index:1;opacity:.8}.home-page .note-group .header-date{font-weight:600;font-size:14.4px;font-size:1.44rem}@media(min-width: 576px){.home-page .note-group .header-date{font-size:14.4px;font-size:1.44rem}}@media(min-width: 992px){.home-page .note-group .header-date{font-size:16px;font-size:1.6rem}}.home-page .note-group .header-count{font-weight:300}.home-page .note-group .list{list-style:none;padding-left:0;margin-bottom:0}.home-page .note-list{list-style:none;padding-left:0;margin-bottom:0}.home-page .note-item{background:#fff;position:relative;border-bottom:1px solid #d8d8d8}.home-page .note-item .link{color:#2a2a2a;display:block;padding:1.2rem 1.6rem;border:2px solid rgba(0,0,0,0)}.home-page .note-item .link:hover{text-decoration:none;background:#ecf4ff;color:inherit}.home-page .note-item .meta{line-height:1.6rem}.home-page .note-item .body{overflow:hidden;text-overflow:ellipsis}.home-page .note-item .note-header{display:flex;justify-content:space-between}.home-page .note-item .note-content{margin-top:1.2rem;line-height:1.6rem;overflow:hidden;text-overflow:ellipsis;color:#686868}.home-page .note-item .book-label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:700;font-size:14px;font-size:1.4rem;width:212px}@media(min-width: 576px){.home-page .note-item .book-label{font-size:14px;font-size:1.4rem}}@media(min-width: 992px){.home-page .note-item .book-label{font-size:14px;font-size:1.4rem}}@media(min-width: 576px){.home-page .note-item .book-label{width:320px}}.home-page .note-item .match{display:inline-block;background:#f7f77d;padding:.4rem .4rem}.home-page .toolbar{text-align:right}.home-page .paginator{display:inline-flex;align-items:center}.home-page .paginator .paginator-info{font-size:14px;font-size:1.4rem;color:#686868}@media(min-width: 576px){.home-page .paginator .paginator-info{font-size:14px;font-size:1.4rem}}@media(min-width: 992px){.home-page .paginator .paginator-info{font-size:14px;font-size:1.4rem}}.home-page .paginator .paginator-link{padding:1.2rem 1.2rem}.home-page .paginator .paginator-link.disabled{cursor:not-allowed}.home-page .paginator .paginator-link-prev{margin-left:.8rem}@media(min-width: 576px){.home-page .paginator .paginator-link-prev{margin-left:2rem}}.home-page .paginator .caret-next{transform:rotate(-90deg)}.home-page .paginator .caret-prev{transform:rotate(90deg)}.home-page .paginator .paginator-label{font-weight:600}.note-page{background:#f3f3f3;flex-grow:1;flex-basis:0}.note-page .header{display:flex;align-items:center;justify-content:space-between;padding:1.2rem 1.6rem;border-bottom:1px solid #d8d8d8}.note-page .header-left,.note-page .header-right{display:flex;align-items:center}.note-page .book-icon{vertical-align:middle}.note-page .content-wrapper{padding:1.2rem 1.6rem}.note-page .collapsed-content{color:#8c8c8c}.note-page .footer{display:flex;justify-content:space-between;align-items:center;font-size:14px;font-size:1.4rem;padding:1.2rem 1.6rem}@media(min-width: 576px){.note-page .footer{font-size:14px;font-size:1.4rem}}@media(min-width: 992px){.note-page .footer{font-size:14px;font-size:1.4rem}}.note-page .ts{color:#8c8c8c}.note-page .ts-lead{display:none}@media(min-width: 576px){.note-page .ts-lead{display:inline}}.note-page .match{display:inline-block;background:#f7f77d}.note-page .book-label{font-size:18px;font-size:1.8rem;font-weight:600;display:inline-block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#2a2a2a}@media(min-width: 576px){.note-page .book-label{font-size:18px;font-size:1.8rem}}@media(min-width: 992px){.note-page .book-label{font-size:18px;font-size:1.8rem}}.note-page .book-label a{color:inherit}.note-page .book-label a:hover{color:inherit}.note-page .header .book-label{max-width:20rem;margin-left:1.2rem}@media(min-width: 321px){.note-page .header .book-label{max-width:20rem}}@media(min-width: 576px){.note-page .header .book-label{max-width:42rem}}@media(min-width: 992px){.note-page .header .book-label{max-width:60rem}}.books-page .books-content{padding:1.6rem 2.4rem;margin-top:1.6rem}.books-page .books-content h1{border-bottom:1px solid #f3f3f3;margin-bottom:1.2rem}:export{mdBreakpoint:576px;smBreakpoint:321px}.settings-page .sidebar{box-shadow:0 1px 5px rgba(0,0,0,.2);background:#fff;margin-bottom:2rem;margin-top:2rem}@media(min-width: 992px){.settings-page .sidebar{margin-bottom:0;margin-top:0}}.settings-page .sidebar-item{display:block;padding:1.2rem 1.6rem;border-left:4px solid rgba(0,0,0,0);font-size:14.4px;font-size:1.44rem}@media(min-width: 576px){.settings-page .sidebar-item{font-size:14.4px;font-size:1.44rem}}@media(min-width: 992px){.settings-page .sidebar-item{font-size:16px;font-size:1.6rem}}.settings-page .sidebar-item:hover{text-decoration:none;background:#f7f9fa}.settings-page .sidebar-item.active{font-weight:600;border-left-color:#072a40}@media(min-width: 992px){.settings-page .setting-section-wrapper .header{display:none}}.settings-page .setting-section-wrapper .setting-section{margin-top:2.4rem;background:#fff;box-shadow:0 0 8px rgba(0,0,0,.14)}.settings-page .setting-section-wrapper .setting-section:first-child{margin-top:0}.settings-page .setting-section-wrapper .section-heading{font-size:14.4px;font-size:1.44rem;font-weight:600;padding-bottom:.4rem;background:#f7f9fa;padding:1.6rem 2rem}@media(min-width: 576px){.settings-page .setting-section-wrapper .section-heading{font-size:14.4px;font-size:1.44rem}}@media(min-width: 992px){.settings-page .setting-section-wrapper .section-heading{font-size:16px;font-size:1.6rem}}.settings-page .setting-section-wrapper .section-content{margin-top:2rem}.settings-page .setting-section-wrapper .actions{margin-top:1.8rem;text-align:right}.settings-page .setting-row{padding:1.6rem 2rem}.settings-page .setting-row:not(:last-child){border-bottom:1px solid #d8d8d8}.settings-page .setting-row .setting-row-summary{display:flex;flex-direction:column}@media(min-width: 576px){.settings-page .setting-row .setting-row-summary{flex-direction:row;justify-content:space-between;align-items:center}}.settings-page .setting-row .setting-row-main{padding-top:2.4rem}.settings-page .setting-row .setting-name{font-weight:400;font-size:14.4px;font-size:1.44rem;margin-bottom:0}@media(min-width: 576px){.settings-page .setting-row .setting-name{font-size:14.4px;font-size:1.44rem}}@media(min-width: 992px){.settings-page .setting-row .setting-name{font-size:16px;font-size:1.6rem}}.settings-page .setting-row .setting-desc{margin-bottom:0;font-size:14px;font-size:1.4rem;color:#686868}@media(min-width: 576px){.settings-page .setting-row .setting-desc{font-size:14px;font-size:1.4rem}}@media(min-width: 992px){.settings-page .setting-row .setting-desc{font-size:14px;font-size:1.4rem}}.settings-page .setting-row .setting-action{display:flex;flex-direction:column}@media(min-width: 576px){.settings-page .setting-row .setting-action{flex-direction:row}}.settings-page .setting-row .setting-right{display:flex;word-break:break-all;justify-content:space-between;align-items:center;margin-top:.4rem}@media(min-width: 576px){.settings-page .setting-row .setting-right{flex-direction:row;align-items:center;margin-top:0}}.settings-page .setting-row .setting-edit{color:#6f53c0;padding:0}.settings-page .setting-row .setting-edit:hover{color:#6143b7}@media(min-width: 576px){.settings-page .setting-row .setting-edit{margin-left:1.6rem}}.settings-page .setting-row .input-row~.input-row,.settings-page .setting-row .input-row .input-row{margin-top:1.2rem}.settings-page .email-verification-form{margin-left:1.2rem}:export{mdBreakpoint:576px;smBreakpoint:321px}.header-wrapper{padding:0;z-index:2;position:relative;display:flex;box-shadow:0 1px 5px rgba(0,0,0,.2);background:#072a40;align-items:stretch;justify-content:space-between;flex:1;flex-direction:column;position:sticky;top:0;z-index:4;height:60px}.header-wrapper .container{height:100%}@media(min-width: 576px){.header-wrapper{flex-direction:row}}.header-wrapper .header-content{display:flex;justify-content:space-between;height:100%}.header-wrapper .left{display:flex}.header-wrapper .right{display:flex}.header-wrapper .search-wrapper{align-items:center;display:flex;margin-left:3.2rem}.header-wrapper .search-input{width:35.6rem;border:0;padding:4px 12px;border-radius:.4rem;font-size:14px;font-size:1.4rem}@media(min-width: 576px){.header-wrapper .search-input{font-size:14px;font-size:1.4rem}}@media(min-width: 992px){.header-wrapper .search-input{font-size:14px;font-size:1.4rem}}.header-wrapper .brand{display:flex;align-items:center}.header-wrapper .brand:hover{text-decoration:none}.header-wrapper .main-nav{margin-left:3.2rem;display:flex}.header-wrapper .main-nav .list{display:flex}.header-wrapper .main-nav .item{display:flex;align-items:stretch}.header-wrapper .main-nav .nav-link{font-size:14px;font-size:1.4rem;display:flex;font-weight:600;align-items:center;padding:0 1.6rem;color:#fff}@media(min-width: 576px){.header-wrapper .main-nav .nav-link{font-size:14px;font-size:1.4rem}}@media(min-width: 992px){.header-wrapper .main-nav .nav-link{font-size:14px;font-size:1.4rem}}.header-wrapper .main-nav .nav-link:hover{color:#fff;text-decoration:none;background:#0c486e}.header-wrapper .main-nav .nav-item{font-size:14px;font-size:1.4rem;font-weight:600}@media(min-width: 576px){.header-wrapper .main-nav .nav-item{font-size:14px;font-size:1.4rem}}@media(min-width: 992px){.header-wrapper .main-nav .nav-item{font-size:14px;font-size:1.4rem}}.header-wrapper .dropdown-trigger{color:#fff;padding:16px;font-size:16px;border:none;cursor:pointer}.header-wrapper .dropdown{position:relative;display:inline-block}.header-wrapper .dropdown-content{display:none;position:absolute;background-color:#f1f1f1;width:24rem;background:#fff;border:1px solid #d8d8d8;border-radius:4px;box-shadow:0 0 3px rgba(0,0,0,.15);top:calc(100% + 4px);z-index:1}.header-wrapper .dropdown-content.show{display:block}.header-wrapper .dropdown-content.right-align{right:0}.header-wrapper .account-dropdown .dropdown-trigger{height:100%}.header-wrapper .account-dropdown .account-dropdown-header{font-size:14px;font-size:1.4rem;color:#8c8c8c;padding:.8rem 1.2rem;display:block;margin-bottom:0;white-space:nowrap}@media(min-width: 576px){.header-wrapper .account-dropdown .account-dropdown-header{font-size:14px;font-size:1.4rem}}@media(min-width: 992px){.header-wrapper .account-dropdown .account-dropdown-header{font-size:14px;font-size:1.4rem}}.header-wrapper .account-dropdown .account-dropdown-header svg{fill:#8c8c8c}.header-wrapper .account-dropdown .account-dropdown-header .email{font-weight:600;white-space:normal;word-break:break-all}.header-wrapper .account-dropdown .dropdown-link{font-size:14px;font-size:1.4rem;white-space:pre;padding:.8rem 1.4rem;width:100%;display:block;color:#000}@media(min-width: 576px){.header-wrapper .account-dropdown .dropdown-link{font-size:14px;font-size:1.4rem}}@media(min-width: 992px){.header-wrapper .account-dropdown .dropdown-link{font-size:14px;font-size:1.4rem}}.header-wrapper .account-dropdown .dropdown-link:hover{background:#f3f3f3;text-decoration:none;color:#0056b3}.header-wrapper .account-dropdown .dropdown-link.disabled{color:#d4d4d4;cursor:not-allowed}.header-wrapper .account-dropdown .dropdown-link:not(.disabled):focus{background:#f3f3f3;color:#0056b3;outline:1px dotted gray}.header-wrapper .account-dropdown .session-notice-wrapper{display:flex;align-items:center}.header-wrapper .account-dropdown .session-notice{margin-left:.4rem}.main{position:relative;display:flex;flex-direction:column;background:#f3f3f3;min-height:calc(100vh - 60px)}.main.nofooter{margin-bottom:0}.main.noheader:not(.nofooter){min-height:calc(100vh - 56px)}.main.nofooter:not(.noheader){min-height:calc(100vh - 60px)}.main.nofooter.noheader{min-height:100vh}@media(min-width: 992px){.main{margin-bottom:0;min-height:calc(100vh - 60px)}}.partial--time{color:#686868;font-size:14px;font-size:1.4rem}@media(min-width: 576px){.partial--time{font-size:14px;font-size:1.4rem}}@media(min-width: 992px){.partial--time{font-size:14px;font-size:1.4rem}}@media(min-width: 576px){.partial--time .mobile-text{display:none}}.partial--time .text{display:none}@media(min-width: 576px){.partial--time .text{display:inherit}}@media(min-width: 992px){.partial--page-toolbar{height:4.8rem;border-radius:.4rem;background:#f7f9fa;box-shadow:0 0 8px rgba(0,0,0,.14)}.partial--page-toolbar.bottom{margin-top:1.2rem}}.icon--caret-right{transform:rotate(-90deg)}.icon--caret-left{transform:rotate(90deg)}.frame{box-shadow:0 1px 5px rgba(0,0,0,.2);background:#fff}html{font-size:62.5%}html body{margin:0;font-size:1.6rem}img{max-width:100%}.main-content{padding-top:24px}.no-scroll{overflow:hidden}@media(min-width: 576px)and (max-width: 991px){.container.mobile-nopadding{max-width:100%}}@media(max-width: 991px){.container.mobile-nopadding{padding-left:0;padding-right:0}.container.mobile-nopadding .row{margin-left:0;margin-right:0}.container.mobile-nopadding [class*=col-]{padding-left:0;padding-right:0}}.form-control{font-size:1.6rem}.dropdown{position:inherit}.input-group input~button{border-top-left-radius:0;border-bottom-left-radius:0}.page-bgdark{background:#ececec}.modal-scrollbar-measure{position:absolute;top:-9999px;width:50px;height:50px;overflow:scroll}.input{border-radius:.4rem;background-clip:padding-box;border:1px solid #ced4da;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}/*# sourceMappingURL=main.css.map */
diff --git a/pkg/server/static/main.css.map b/pkg/server/static/main.css.map
new file mode 100644
index 00000000..dfbdb1d3
--- /dev/null
+++ b/pkg/server/static/main.css.map
@@ -0,0 +1 @@
+{"version":3,"sourceRoot":"","sources":["../assets/styles/src/_reboot.scss","../assets/styles/src/_grid.scss","../assets/styles/src/_bootstrap.scss","../assets/styles/src/_variables.scss","../assets/styles/src/_buttons.scss","../assets/styles/src/_font.scss","../assets/styles/src/_responsive.scss","../assets/styles/src/_theme.scss","../assets/styles/src/_select.scss","../assets/styles/src/_shared.scss","../assets/styles/src/_marker.scss","../assets/styles/src/_markdown.scss","../assets/styles/src/_hljs.scss","../assets/styles/src/_login.scss","../assets/styles/src/_home.scss","../assets/styles/src/_note.scss","../assets/styles/src/_books.scss","../assets/styles/src/_settings.scss","../assets/styles/src/_header.scss","../assets/styles/src/_global.scss","../assets/styles/src/main.scss"],"names":[],"mappings":"AAkBA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,GAOA,qBAGE,sBAGF,KACE,uBACA,iBACA,8BACA,0CAGF,sEAUE,cAGF,KACE,SACA,uLAGA,eACA,gBACA,gBACA,cACA,gBACA,sBAGF,sBACE,qBAGF,GACE,uBACA,SACA,iBAGF,kBAME,aACA,oBAGF,EACE,aACA,mBAGF,sCAEE,0BACA,yCACA,iCACA,YACA,gBACA,sCACA,8BAGF,QACE,mBACA,kBACA,oBAGF,SAGE,aACA,mBAGF,wBAIE,gBAGF,GACE,gBAGF,GACE,oBACA,cAGF,WACE,gBAGF,SAEE,mBAGF,MACE,cAGF,QAEE,kBACA,cACA,cACA,wBAGF,IACE,eAGF,IACE,WAGF,EACE,cACA,qBACA,+BAGF,QACE,cACA,0BAGF,8BACE,cACA,qBAGF,wEAEE,cACA,qBAGF,oCACE,UAGF,kBAIE,2FAEA,cAGF,IACE,aACA,mBACA,cAGF,OACE,gBAGF,IACE,sBACA,kBAGF,IACE,gBACA,sBAGF,MACE,yBAGF,QACE,mBACA,sBACA,cACA,gBACA,oBAGF,GACE,mBAGF,MACE,qBACA,oBAGF,OACE,gBAGF,aACE,mBACA,0CAGF,sCAKE,SACA,oBACA,kBACA,oBAGF,aAEE,iBAGF,cAEE,oBAGF,OACE,iBAGF,gDAIE,0BAGF,4GAIE,eAGF,wHAIE,UACA,kBAGF,uCAEE,sBACA,UAGF,+EAIE,2BAGF,SACE,cACA,gBAGF,SACE,YACA,UACA,SACA,SAGF,OACE,cACA,WACA,eACA,UACA,oBACA,iBACA,oBACA,cACA,mBAGF,SACE,wBAGF,kFAEE,YAGF,cACE,oBACA,wBAGF,yCACE,wBAGF,6BACE,aACA,0BAGF,OACE,qBAGF,QACE,kBACA,eAGF,SACE,aAGF,SACE,wBC1VF;AAAA;AAAA;AAAA;AAAA;AAAA,GAMA,KACE,sBACA,6BAGF,qBAGE,mBAGF,gBACE,WACA,mBACA,kBACA,kBACA,iBAGF,yBACE,gBACE,iBAIJ,yBACE,gBACE,iBAIJ,yBACE,gBACE,iBAIJ,0BACE,gBACE,kBAIJ,0BACE,gBACE,kBAIJ,0BACE,gBACE,kBAIJ,iBACE,WACA,mBACA,kBACA,kBACA,iBAGF,WACE,WACA,mBACA,kBACA,kBACA,iBAGF,yBACE,WACE,iBAIJ,yBACE,WACE,iBAIJ,yBACE,WACE,iBAIJ,0BACE,WACE,kBAIJ,KACE,oBACA,aACA,mBACA,eACA,mBACA,kBAGF,YACE,eACA,cAGF,2CAEE,gBACA,eAGF,sqBAsEE,kBACA,WACA,mBACA,kBAGF,KACE,0BACA,aACA,oBACA,YACA,eAGF,UACE,kBACA,cACA,WACA,eAGF,OACE,uBACA,mBACA,oBAGF,OACE,wBACA,oBACA,qBAGF,OACE,iBACA,aACA,cAGF,OACE,wBACA,oBACA,qBAGF,OACE,wBACA,oBACA,qBAGF,OACE,iBACA,aACA,cAGF,OACE,wBACA,oBACA,qBAGF,OACE,wBACA,oBACA,qBAGF,OACE,iBACA,aACA,cAGF,QACE,wBACA,oBACA,qBAGF,QACE,wBACA,oBACA,qBAGF,QACE,kBACA,cACA,eAGF,aACE,kBACA,SAGF,YACE,kBACA,SAGF,SACE,iBACA,QAGF,SACE,iBACA,QAGF,SACE,iBACA,QAGF,SACE,iBACA,QAGF,SACE,iBACA,QAGF,SACE,iBACA,QAGF,SACE,iBACA,QAGF,SACE,iBACA,QAGF,SACE,iBACA,QAGF,SACE,iBACA,QAGF,UACE,kBACA,SAGF,UACE,kBACA,SAGF,UACE,kBACA,SAGF,UACE,sBAGF,UACE,uBAGF,UACE,gBAGF,UACE,uBAGF,UACE,uBAGF,UACE,gBAGF,UACE,uBAGF,UACE,uBAGF,UACE,gBAGF,WACE,uBAGF,WACE,uBAGF,yBACE,QACE,0BACA,aACA,oBACA,YACA,eAEF,aACE,kBACA,cACA,WACA,eAEF,UACE,uBACA,mBACA,oBAEF,UACE,wBACA,oBACA,qBAEF,UACE,iBACA,aACA,cAEF,UACE,wBACA,oBACA,qBAEF,UACE,wBACA,oBACA,qBAEF,UACE,iBACA,aACA,cAEF,UACE,wBACA,oBACA,qBAEF,UACE,wBACA,oBACA,qBAEF,UACE,iBACA,aACA,cAEF,WACE,wBACA,oBACA,qBAEF,WACE,wBACA,oBACA,qBAEF,WACE,kBACA,cACA,eAEF,gBACE,kBACA,SAEF,eACE,kBACA,SAEF,YACE,iBACA,QAEF,YACE,iBACA,QAEF,YACE,iBACA,QAEF,YACE,iBACA,QAEF,YACE,iBACA,QAEF,YACE,iBACA,QAEF,YACE,iBACA,QAEF,YACE,iBACA,QAEF,YACE,iBACA,QAEF,YACE,iBACA,QAEF,aACE,kBACA,SAEF,aACE,kBACA,SAEF,aACE,kBACA,SAEF,aACE,cAEF,aACE,sBAEF,aACE,uBAEF,aACE,gBAEF,aACE,uBAEF,aACE,uBAEF,aACE,gBAEF,aACE,uBAEF,aACE,uBAEF,aACE,gBAEF,cACE,uBAEF,cACE,wBAIJ,yBACE,QACE,0BACA,aACA,oBACA,YACA,eAEF,aACE,kBACA,cACA,WACA,eAEF,UACE,uBACA,mBACA,oBAEF,UACE,wBACA,oBACA,qBAEF,UACE,iBACA,aACA,cAEF,UACE,wBACA,oBACA,qBAEF,UACE,wBACA,oBACA,qBAEF,UACE,iBACA,aACA,cAEF,UACE,wBACA,oBACA,qBAEF,UACE,wBACA,oBACA,qBAEF,UACE,iBACA,aACA,cAEF,WACE,wBACA,oBACA,qBAEF,WACE,wBACA,oBACA,qBAEF,WACE,kBACA,cACA,eAEF,gBACE,kBACA,SAEF,eACE,kBACA,SAEF,YACE,iBACA,QAEF,YACE,iBACA,QAEF,YACE,iBACA,QAEF,YACE,iBACA,QAEF,YACE,iBACA,QAEF,YACE,iBACA,QAEF,YACE,iBACA,QAEF,YACE,iBACA,QAEF,YACE,iBACA,QAEF,YACE,iBACA,QAEF,aACE,kBACA,SAEF,aACE,kBACA,SAEF,aACE,kBACA,SAEF,aACE,cAEF,aACE,sBAEF,aACE,uBAEF,aACE,gBAEF,aACE,uBAEF,aACE,uBAEF,aACE,gBAEF,aACE,uBAEF,aACE,uBAEF,aACE,gBAEF,cACE,uBAEF,cACE,wBAIJ,yBACE,QACE,0BACA,aACA,oBACA,YACA,eAEF,aACE,kBACA,cACA,WACA,eAEF,UACE,uBACA,mBACA,oBAEF,UACE,wBACA,oBACA,qBAEF,UACE,iBACA,aACA,cAEF,UACE,wBACA,oBACA,qBAEF,UACE,wBACA,oBACA,qBAEF,UACE,iBACA,aACA,cAEF,UACE,wBACA,oBACA,qBAEF,UACE,wBACA,oBACA,qBAEF,UACE,iBACA,aACA,cAEF,WACE,wBACA,oBACA,qBAEF,WACE,wBACA,oBACA,qBAEF,WACE,kBACA,cACA,eAEF,gBACE,kBACA,SAEF,eACE,kBACA,SAEF,YACE,iBACA,QAEF,YACE,iBACA,QAEF,YACE,iBACA,QAEF,YACE,iBACA,QAEF,YACE,iBACA,QAEF,YACE,iBACA,QAEF,YACE,iBACA,QAEF,YACE,iBACA,QAEF,YACE,iBACA,QAEF,YACE,iBACA,QAEF,aACE,kBACA,SAEF,aACE,kBACA,SAEF,aACE,kBACA,SAEF,aACE,cAEF,aACE,sBAEF,aACE,uBAEF,aACE,gBAEF,aACE,uBAEF,aACE,uBAEF,aACE,gBAEF,aACE,uBAEF,aACE,uBAEF,aACE,gBAEF,cACE,uBAEF,cACE,wBAIJ,0BACE,QACE,0BACA,aACA,oBACA,YACA,eAEF,aACE,kBACA,cACA,WACA,eAEF,UACE,uBACA,mBACA,oBAEF,UACE,wBACA,oBACA,qBAEF,UACE,iBACA,aACA,cAEF,UACE,wBACA,oBACA,qBAEF,UACE,wBACA,oBACA,qBAEF,UACE,iBACA,aACA,cAEF,UACE,wBACA,oBACA,qBAEF,UACE,wBACA,oBACA,qBAEF,UACE,iBACA,aACA,cAEF,WACE,wBACA,oBACA,qBAEF,WACE,wBACA,oBACA,qBAEF,WACE,kBACA,cACA,eAEF,gBACE,kBACA,SAEF,eACE,kBACA,SAEF,YACE,iBACA,QAEF,YACE,iBACA,QAEF,YACE,iBACA,QAEF,YACE,iBACA,QAEF,YACE,iBACA,QAEF,YACE,iBACA,QAEF,YACE,iBACA,QAEF,YACE,iBACA,QAEF,YACE,iBACA,QAEF,YACE,iBACA,QAEF,aACE,kBACA,SAEF,aACE,kBACA,SAEF,aACE,kBACA,SAEF,aACE,cAEF,aACE,sBAEF,aACE,uBAEF,aACE,gBAEF,aACE,uBAEF,aACE,uBAEF,aACE,gBAEF,aACE,uBAEF,aACE,uBAEF,aACE,gBAEF,cACE,uBAEF,cACE,wBC7jCJ,cACE,cACA,WACA,uBACA,eACA,gBACA,cACA,sBACA,4BACA,yBACA,qBACA,qEAGF,OACE,kBACA,wBACA,+BAGF,eACE,cAGF,YACE,gBAOF,0BACE,kBACA,MACA,QACA,uBACA,cAGF,eACE,cACA,yBACA,qBAGF,kBACE,yBAGF,2BACE,cAGF,iBACE,cACA,yBACA,qBAGF,oBACE,yBAGF,6BACE,cAGF,eACE,cACA,yBACA,qBAGF,kBACE,yBAGF,2BACE,cAGF,YACE,cACA,yBACA,qBAGF,eACE,yBAGF,wBACE,cAGF,eACE,cACA,yBACA,qBAGF,kBACE,yBAGF,2BACE,cAGF,cACE,cACA,yBACA,qBAGF,iBACE,yBAGF,0BACE,cAGF,aACE,cACA,yBACA,qBAGF,gBACE,yBAGF,yBACE,cAGF,YACE,cACA,yBACA,qBAGF,eACE,yBAGF,wBACE,cAIF,YACE,uBCnJF,QACE,aAJc,MAKd,aAJc,MCuBhB,QACE,kBACA,qBACA,kBACA,mBACA,sBACA,iBACA,qBACA,qCACA,wBACA,uCAEA,qBACA,eAEA,2BACE,iBACA,mBACA,2BAGF,6BACE,qBAGF,iBACE,mBACA,WAGF,cACE,2BAIJ,gBACE,mBACA,WAGF,cCMI,eACA,iBDLF,qBE5DE,yBF0DJ,cCUM,eACA,kBCzEF,yBF8DJ,cCeM,eACA,kBDXN,eAEE,qBAGF,cCJI,eACA,iBDMF,qBEvEE,yBFoEJ,6BCCM,kBCzEF,yBFwEJ,cCKM,eACA,kBC1EF,yBFoEJ,cAMI,uBE9EA,yBFwEJ,cAUI,uBAIJ,eClBI,iBACA,kBDoBF,sBErFE,yBFkFJ,eCdM,iBACA,mBCzEF,yBFsFJ,eCTM,eACA,kBC1EF,yBFkFJ,eAMI,uBE5FA,yBFsFJ,eAUI,uBAIJ,cAvGE,MAwGgB,KAvGhB,iBAuGyB,QArGzB,mCACE,MAoGc,KAnGd,yBACA,mCAqGJ,sBAhGE,yBACA,MAgGwB,QA9FxB,yCACE,aA6F+B,QA5F/B,iBAGF,2CACE,MAwFsB,QAvFtB,mCA0FJ,eA/GE,MGJM,QHKN,iBGKO,QHHP,oCACE,MGRI,QHSJ,yBACA,mCA6GJ,uBAxGE,yBACA,MGhBM,QHkBN,0CACE,aGTK,QHUL,iBAGF,4CACE,MGxBI,QHyBJ,mCAkGJ,cAvHE,MAwHgB,KAvHhB,iBGMM,QHJN,mCACE,MAoHc,KAnHd,yBACA,mCAqHJ,sBAhHE,yBACA,MGLM,QHON,yCACE,aGRI,QHSJ,iBAGF,2CACE,MGbI,QHcJ,mCA0GJ,eApHE,yBACA,MGIY,QHiHZ,gBAnHA,kCACE,aGCU,yBHGZ,oCACE,MGJU,QHKV,mCA+GJ,gBACE,WAGF,gBACE,mBAGF,cACE,YACA,gBACA,gBACA,eAGF,mBACE,UAGF,aACE,MG3IK,QH6IL,mBACE,MG7IS,QH8IT,0BDxJJ,QACE,aAJc,MAKd,aAJc,cKCd,kBAEF,qGAEE,wBAEF,yBACE,wBAEF,0BACE,wBAEF,+CAIE,8BACA,2BACA,sBAEF,uCACE,eACA,oBACA,YAEF,oCACE,yBAEF,0CACE,gBAEF,gCACE,6BACA,4BACA,gBAEF,8CACE,SACA,8CACA,uBAEF,8CACE,YAEF,+DACE,YAEF,mCACE,gBAEF,mCACE,yBACA,4BAEF,iDACE,gBAEF,4EACE,mBAEF,wLAKE,WAEF,0LAKE,eACA,qBAEF,4YAUE,cACA,aACA,0BAEF,sMAKE,gBAEF,kDACE,UAEF,6EAEE,sBAEF,oBACE,cACA,iBAEF,gBACE,sBACA,WACA,eACA,cACA,6BACA,gCACA,YACA,aACA,gBACA,kBACA,WAKF,oCACE,aACA,gBAEF,kEAEE,SACA,gBACA,cACA,OACA,iBAEA,kBACA,mBACA,kBACA,QACA,MACA,eACA,gBACA,uBACA,mBAEF,cACE,YACA,kBACA,mBACA,sBAEF,oBACE,WACA,8BACA,cACA,gBACA,eACA,qBACA,oBACA,kBACA,SACA,aACA,iBAEA,mBAEA,wBAEF,gCACE,YAEF,2CACE,UAEF,kDACE,aAEF,qBACE,eACA,mBACA,kBACA,kBACA,sBACA,WAEF,gBACE,8DACA,yDACA,sDACA,WACA,YACA,sBACA,kBACA,sBACA,wBACA,qBACA,kBACA,sBAEF,mBACE,gDACA,2CACA,wCACA,WACA,eACA,mBACA,kBACA,kBACA,sBACA,WAEF,yBACE,cAEF,cACE,qBACA,eACA,cAEF,kCACE,WAEF,mBACE,eACA,mBACA,kBACA,kBACA,sBACA,WACA,kBAEF,gCACE,gBACA,iBAEF,cACE,8CACA,mBACA,2BACA,qBACA,SACA,QACA,kBAEF,6BACE,kBAEF,2CACE,qBAEF,0BACE,kBACA,qBACA,WACA,UACA,YACA,sBACA,gBACA,WAEF,2CACE,KACE,UAEF,GACE,WAGJ,mCACE,KACE,UAEF,GACE,WAGJ,mBACE,+BACA,8BACA,sBACA,sBACA,yBACA,mCACA,sBACA,gBACA,iBACA,kBACA,OACA,SACA,WACA,UACA,iCAEF,mCACE,yBAEF,aACE,iBACA,gBAEF,eACE,sBACA,sBACA,WACA,eACA,cACA,iBAEF,0BACE,+BACA,8BAEF,2BACE,yBAEA,qCACA,WAEF,0BACE,yBAEA,qCACA,WAEF,2BACE,WACA,eAEF,kBACE,sBACA,WACA,eACA,cACA,iBAEF,6BACE,sBACA,iBACA,UAEF,yCACE,cACA,kBAEF,uCACE,gBAEF,6BACE,yBAEA,qCACA,kBACA,yBAEA,qCACA,cACA,qBACA,eACA,gBACA,gBACA,eACA,mBAEF,qEAEE,qBACA,sBAEF,mCACE,+BACA,4BACA,eACA,gBAEF,oCACE,cACA,eACA,qBAEF,0CACE,0BAEF,kCACE,eACA,8BACA,2BACA,+BAEA,2CACA,oBAEF,gFAEE,yBAEA,qCACA,cAEF,yCACE,yBAEA,qCAEF,yCACE,cACA,iBAEF,8CACE,kBACA,8BAEA,0CAEF,yCACE,yBACA,yBACA,WAEF,8CACE,mBACA,+BAEF,6JAGE,yBAEF,iCACE,GACE,yBAGJ,yCACE,GACE,iCLjbJ,QACE,aAJc,MAKd,aAJc,MAEhB,QACE,aAJc,MAKd,aAJc,MMJhB,uBACE,GACE,WAEF,IACE,UAEF,KACE,YAKJ,QACE,qCACA,mBAEA,oBACE,mBAIJ,iIAKE,iBFxBa,QEyBb,mBAGF,eACE,gBACA,eACA,gBAGF,SACE,aAGF,mBACE,kBACA,YACA,WACA,YACA,gBAIA,sBAEE,cAIJ,YACE,yBACA,qBACA,kBACA,oBACA,cAEA,yBACE,MF/DG,QEiEL,kBACE,aF7CS,QE8CT,6EAEA,aAIJ,kBACE,qBAGF,mBACE,qBAGF,oBACE,WAGF,YACE,WAGF,EACE,MF5EK,QE8EL,QACE,MF9ES,QEmFb,kBAME,gBH5EE,yBGgFJ,qBAEI,gBHlFA,yBGqFJ,4BAEI,eACA,gBAEA,iCACE,cACA,eAEF,0CAEE,eACA,iBAIN,UACE,kBAGF,MACE,iBACA,oBAEA,uBACE,cACA,iBHvIA,yBGqIF,uBAKI,mBACA,uBAKN,aACE,gBAEA,8BACE,mBHpJA,yBGgJJ,aASI,mBACA,cAIJ,aACE,gBACA,mZACA,sBACA,4BACA,qCACA,yBACA,yBACA,gBACA,gBACA,mBACA,aACA,sBACA,kBACA,8CAEA,mBACE,qBACA,aACA,2EAGF,wDAEE,oiBACA,iBFzLW,QE6Lf,aAEE,WACA,gBACA,oBJ3HE,eACA,iBCjEA,yBGuLJ,aJnHM,eACA,kBCzEF,yBG2LJ,aJ9GM,eACA,kBIqHN,cJ/HI,iBACA,kBCjEA,yBG+LJ,cJ3HM,iBACA,mBCzEF,yBGmMJ,cJtHM,eACA,kBIyHN,gBACE,qBACA,sBACA,qBACA,uBACA,qCACA,oCACA,oCACA,kBAGF,SACE,SACA,gBACA,6BC7NF,QACE,qBACA,mBACA,cACA,gBACA,cACA,kBACA,mBACA,wBACA,qBAGF,cACE,WACA,yBAGF,aACE,WACA,yBCPF,uBACE,WACA,cACA,kBACA,kBAGF,6BACE,aAGF,gMAME,qBAGF,eACE,0BACA,8BACA,cACA,gBACA,kIAEA,eACA,gBACA,qBAGF,qBACE,cAGF,iDAEE,cAGF,2CAEE,cAGF,mDAEE,cAGF,uBACE,cAGF,qBACE,cAGF,gMAOE,cAGF,4CAEE,cAGF,sBACE,cAGF,sBACE,yBACA,cAGF,sBACE,yBACA,cAGF,6BACE,aAGF,8BACE,cACA,gBAGF,sBACE,cAGF,yEAGE,cACA,gBAGF,sBACE,cACA,kBAGF,sBACE,cACA,gBAGF,sBACE,yBACA,cAGF,uBACE,yBACA,cAGF,sBACE,yBACA,cAGF,uBACE,yBACA,cAGF,uBACE,cACA,gBAGF,sBACE,cAGF,sBACE,cAGF,wBACE,cACA,0BAGF,uBACE,cAGF,uBACE,kBAGF,iBACE,+BAGF,+CAEE,gBAGF,sBACE,oBACA,mBAGF,kBACE,cACA,eAGF,mBACE,kBAGF,0DAGE,gCACA,cAGF,kBACE,uBACA,SACA,iBAGF,qBACE,aACA,SAGF,qBACE,iBAGF,+BACE,sBACA,UAGF,iBACE,sBAGF,qBACE,oBACA,kBACA,oBAGF,iBACE,cACA,qBAGF,uBACE,0BAGF,sBACE,gBAGF,kBACE,yBACA,SACA,gCACA,SACA,cACA,gBAGF,yBACE,WACA,cAGF,wBACE,WACA,WACA,cAGF,qBACE,yBACA,iBAGF,oCAEE,UAGF,+BACE,eAGF,4GAME,gBACA,aAGF,kBACE,eAGF,oCAEE,gBAGF,kBACE,eAGF,kBACE,eAGF,oCAEE,gBAGF,kBACE,eAGF,kBACE,eAGF,oCAEE,gBAGF,kBACE,eAGF,iBACE,mBACA,aAGF,0BACE,SAGF,oCAEE,gBACA,aACA,eAGF,0CAEE,4BAGF,gGAIE,4BAGF,kBACE,cAGF,uCAEE,4EAEA,eAGF,mBACE,gBACA,aAGF,gGAEE,wBACA,gBACA,SAGF,uBACE,oCAGF,yBACE,oBAGF,8BACE,2CAGF,0BACE,6BAGF,yBACE,iCAGF,8BACE,oCAGF,gCACE,yBAGF,qBACE,2BAGF,qBACE,6BACA,0BAGF,qBACE,0BAGF,qBACE,4BACA,yBAGF,qBACE,4BAGF,qBACE,4BAGF,qBACE,8BACA,2BAGF,0CAEE,6BAGF,qBACE,8BAGF,qBACE,6BAGF,qBACE,6BAGF,qBACE,6BAGF,mBACE,0BAGF,6BACE,4BAGF,0BACE,2BAGF,sBACE,WACA,cAGF,qBACE,WACA,WACA,cAGF,4BACE,wBAGF,2BACE,2BAGF,6BACE,cACA,qBAGF,yIAOE,mBACA,aAGF,kBACE,yBACA,SACA,aACA,cACA,UAGF,0BACE,gCACA,cACA,cAGF,uCACE,aAGF,sCACE,gBAGF,mBACE,yBACA,yBACA,4BACA,kBACA,kCACA,cACA,qBACA,eACA,iBACA,gBACA,sBAGF,4GAME,gBACA,iBACA,mBACA,gBAGF,kBACE,cAGF,oCAEE,oBAGF,kBACE,gCAGF,kBACE,gBAGF,kBACE,iBAGF,kBACE,cAGF,kBACE,iBAGF,kBACE,cACA,gBAGF,oCAEE,iBAGF,oFAIE,gBACA,aAGF,kBACE,oBAGF,oBACE,gBAGF,qBACE,iBAGF,kBACE,UAGF,qBACE,cACA,kBACA,gBACA,gBACA,UAGF,qBACE,mBACA,eAGF,qBACE,cACA,cACA,WAGF,wBACE,gBAGF,gDAEE,yBACA,iBAGF,wBACE,sBACA,6BAGF,sCACE,yBAGF,mBACE,sBACA,uBACA,eAGF,gCACE,kBAGF,+BACE,mBAGF,oBACE,oCACA,kBACA,cACA,SACA,kBAGF,mBACE,iBAGF,wBACE,yBACA,SACA,eACA,SACA,UACA,gBACA,kBACA,qBAGF,0BACE,mBAGF,8BACE,gBACA,kBAGF,iDAEE,yBACA,kBACA,cACA,iBACA,cACA,aAGF,wBACE,+BACA,SACA,eACA,oBACA,SACA,eACA,iBACA,UACA,iBAGF,iCACE,cACA,qBACA,4EAEA,cAGF,6BACE,8BACA,+BACA,gBACA,kBAGF,sCACE,iBACA,gBAGF,yBACE,sBACA,qBACA,yBACA,wBACA,eACA,4EAEA,eACA,iBACA,eACA,kBACA,mBACA,iBACA,iBACA,mBACA,mBACA,SAGF,+BACE,wBAGF,gCACE,+BAGF,0BACE,iBACA,kBACA,mBACA,kBACA,mBAGF,gCACE,cACA,4EAEA,eACA,iBACA,gBACA,iBAGF,+DAEE,mBACA,eAGF,mBACE,yBACA,yBACA,4BACA,kBACA,kCACA,cACA,qBACA,0EAEA,iBACA,gBACA,sBAGF,qCACE,qBACA,kBACA,UAGF,4CACE,gBACA,WAGF,4CACE,gBACA,WAGF,4CACE,gBACA,WAGF,4CACE,gBACA,WAGF,4CACE,gBACA,WAGF,4CACE,gBACA,WAGF,4CACE,gBACA,WAGF,4CACE,gBACA,WAGF,4CACE,gBACA,WAGF,6CACE,iBACA,YAGF,6CACE,iBACA,YAGF,6CACE,iBACA,YAGF,+BACE,qBAGF,+CACE,eAGF,qCACE,2BACA,sBAGF,kBACE,yBAGF,qBACE,0BAGF,qBACE,4BAGF,qBACE,4BAGF,qBACE,6BAGF,qBACE,6BAGF,qBACE,6BAGF,qBACE,6BAGF,qBACE,6BAGF,qBACE,6BAGF,qBACE,6BAGF,sBACE,6BAGF,sBACE,8BAGF,sBACE,8BC94BF,MACE,cACA,gBACA,aACA,WACA,mBAGF,0BAEE,WACA,kBAGF,6CAGE,WACA,iBAGF,uFAKE,WAGF,0BAEE,WAGF,4CAGE,WACA,iBAGF,YACE,mBAGF,mCAEE,WACA,iBAGF,qCAGE,WACA,mBAGF,wBAEE,cAGF,0BAEE,cAGF,kCAEE,cAGF,WACE,WACA,iBAGF,eACE,gBAGF,eACE,gBAGF,eACE,kBAGF,aACE,iBTtHF,QACE,aAJc,MAKd,aAJc,MUtBhB,WACE,WNoBa,QMnBb,kBACA,iBACA,eAEA,wBACE,eAGF,oBACE,MNKI,QF2EJ,iBACA,kBQ/EA,gBACA,gBACA,gBPYA,yBOjBF,oBRqFI,iBACA,mBCzEF,yBObF,oBR0FI,eACA,kBQnFJ,iBACE,gBACA,iBACA,kBACA,gBAGF,2BACE,cAEF,wBACE,mBAGF,mBACE,gBACA,iBAGF,oBACE,cRqDA,eACA,iBCjEA,yBOUF,oBR0DI,eACA,kBCzEF,yBOcF,oBR+DI,eACA,kBQ5DJ,gBRkDE,eACA,iBCjEA,yBOcF,gBRsDI,eACA,kBCzEF,yBOkBF,gBR2DI,eACA,kBQxDJ,kBACE,yBACA,WN9BI,KM+BJ,kBACA,aACA,gBAGF,wBACE,gBAIA,iCACE,gBAGJ,kBR6BE,eACA,iBQ5BA,gBACA,WACA,gBPvCA,yBOmCF,kBRiCI,eACA,kBCzEF,yBOuCF,kBRsCI,eACA,kBQhCJ,mBRsBE,eACA,iBQrBA,YACA,gBP7CA,yBO0CF,mBR0BI,eACA,kBCzEF,yBO8CF,mBR+BI,eACA,kBQzBF,4CACE,kBAIJ,kBACE,mBV1DJ,QACE,aAJc,MAKd,aAJc,MWrBd,4BACE,YRqBA,yBQtBF,4BAII,mBAGF,mDACE,oBACA,kBACA,MPQC,QOJL,uBACE,kBACA,kBACA,mCAEA,2CACE,gBRMF,yBQPA,2CAII,mBAIJ,0CT+DA,eACA,iBS9DE,aACA,8BACA,WACA,sBACA,WPhBE,QOiBF,MPnBE,QOoBF,gCACA,2BACA,4BRXF,yBQCA,0CTmEE,eACA,kBCzEF,yBQKA,0CTwEE,eACA,kBS5DF,6BACE,gBTiDF,eACA,iBCjEA,yBQcA,6BTsDE,eACA,kBCzEF,yBQkBA,6BT2DE,eACA,kBSvDF,6BACE,kBACA,MACA,SACA,OACA,QACA,gBACA,UACA,WAGF,oCACE,gBTiCF,iBACA,kBCjEA,yBQ8BA,oCTsCE,iBACA,mBCzEF,yBQkCA,oCT2CE,eACA,kBSxCF,qCACE,gBAGF,6BACE,gBACA,eACA,gBAIJ,sBACE,gBACA,eACA,gBAGF,sBACE,gBACA,kBAEA,gCAEA,4BACE,MPrEE,QOsEF,cACA,sBACA,+BAEA,kCACE,qBACA,WPpDK,QOqDL,cAIJ,4BACE,mBAGF,4BACE,gBACA,uBAGF,mCACE,aACA,8BAGF,oCACE,kBACA,mBACA,gBACA,uBACA,MPjGC,QOoGH,kCACE,gBACA,uBACA,mBACA,gBThCF,eACA,iBSkCE,YRnGF,yBQ4FA,kCTxBE,eACA,kBCzEF,yBQgGA,kCTnBE,eACA,kBC1EF,yBQ4FA,kCAUI,aAIJ,6BACE,qBACA,mBACA,oBAIJ,oBACE,iBAGF,sBACE,oBACA,mBAEA,sCTzDA,eACA,iBS0DE,MPnIC,QDQH,yBQyHA,sCTrDE,eACA,kBCzEF,yBQ6HA,sCThDE,eACA,kBSoDF,sCACE,sBAEA,+CACE,mBAIJ,2CACE,kBRvIF,yBQsIA,2CAII,kBAIJ,kCACE,yBAGF,kCACE,wBAGF,uCACE,gBCrLN,WAEE,WRsBa,QQrBb,YACA,aAcA,mBACE,aACA,mBACA,8BACA,sBACA,gCAEF,iDAEE,aACA,mBAGF,sBACE,sBAGF,4BACE,sBAGF,8BACE,MRjBS,QQoBX,mBACE,aACA,8BACA,mBVgDA,eACA,iBU/CA,sBTlBA,yBSaF,mBVuDI,eACA,kBCzEF,yBSiBF,mBV4DI,eACA,kBUrDJ,eACE,MR7BS,QQ+BX,oBACE,aTzBA,yBSwBF,oBAGI,gBAIJ,kBACE,qBACA,mBAGF,uBV4BE,eACA,iBU3BA,gBACA,qBACA,gBACA,uBACA,mBACA,MRtDI,QDWJ,yBSoCF,uBVgCI,eACA,kBCzEF,yBSwCF,uBVqCI,eACA,kBU7BF,yBACE,cAEA,+BACE,cAOJ,+BACE,gBACA,mBTtDF,yBSoDA,+BAKI,iBT7DJ,yBSwDA,+BAQI,iBTpEJ,yBS4DA,+BAWI,iBChGN,2BACE,sBACA,kBAEA,8BACE,gCACA,qBboBN,QACE,aAJc,MAKd,aAJc,McrBd,wBACE,oCACA,gBACA,mBACA,gBXkBA,yBWtBF,wBAOI,gBACA,cAIJ,6BACE,cACA,sBACA,oCZ2EA,iBACA,kBCjEA,yBWdF,6BZkFI,iBACA,mBCzEF,yBWVF,6BZuFI,eACA,kBYlFF,mCACE,qBACA,WVHE,QUMJ,oCACE,gBACA,kBVDE,QDFJ,yBWQA,gDAEI,cAIJ,yDACE,kBACA,gBACA,mCAEA,qEACE,aAIJ,yDZ4CA,iBACA,kBY3CE,gBACA,qBACA,WVjCE,QUkCF,oBXzBF,yBWoBA,yDZgDE,iBACA,mBCzEF,yBWwBA,yDZqDE,eACA,kBY/CF,yDACE,gBAGF,iDACE,kBACA,iBAIJ,4BACE,oBAEA,6CACE,gCAGF,iDACE,aACA,sBX9CF,yBW4CA,iDAMI,mBACA,8BACA,oBAIJ,8CACE,mBAGF,0CACE,gBZGF,iBACA,kBYFE,gBX/DF,yBW4DA,0CZQE,iBACA,mBCzEF,yBWgEA,0CZaE,eACA,kBYTF,0CACE,gBZFF,eACA,iBYGE,MV5EC,QDQH,yBWiEA,0CZGE,eACA,kBCzEF,yBWqEA,0CZQE,eACA,kBYJF,4CACE,aACA,sBXxEF,yBWsEA,4CAKI,oBAIJ,2CACE,aACA,qBACA,8BACA,mBACA,iBXpFF,yBW+EA,2CAQI,mBACA,mBACA,cAIJ,0CACE,MVxFC,QUyFD,UAEA,gDACE,MV3FK,QDPT,yBW6FA,0CAQI,oBAKF,oGAEE,kBAKN,wCACE,mBdrHJ,QACE,aAJc,MAKd,aAJc,MetBhB,gBACE,UACA,UACA,kBACA,aACA,oCACA,WXmBM,QWlBN,oBACA,8BACA,OACA,sBACA,gBACA,MACA,UACA,OfCc,KeCd,2BACE,YZUA,yBY3BJ,gBAqBI,oBAGF,gCACE,aACA,8BACA,YAGF,sBACE,aAGF,uBACE,aAGF,gCACE,mBACA,aACA,mBAGF,8BACE,cACA,SACA,iBACA,oBb2CA,eACA,iBCjEA,yBYiBF,8BbmDI,eACA,kBCzEF,yBYqBF,8BbwDI,eACA,kBajDJ,uBACE,aACA,mBAEA,6BACE,qBAIJ,0BACE,mBACA,aAEA,gCACE,aAGF,gCACE,aACA,oBAGF,oCbiBA,eACA,iBahBE,aACA,gBACA,mBACA,iBACA,MX/DE,KDUJ,yBY+CA,oCbqBE,eACA,kBCzEF,yBYmDA,oCb0BE,eACA,kBanBA,0CACE,MXlEA,KWmEA,qBACA,mBAIJ,oCbEA,eACA,iBaDE,gBZhEF,yBY8DA,oCbME,eACA,kBCzEF,yBYkEA,oCbWE,eACA,kBaNJ,kCACE,WACA,aACA,eACA,YACA,eAGF,0BACE,kBACA,qBAGF,kCACE,aACA,kBACA,yBACA,YACA,gBACA,yBACA,kBACA,mCACA,qBACA,UAEA,uCACE,cAGF,8CACE,QAKF,oDACE,YAGF,2Db3CA,eACA,iBa4CE,MXpHO,QWqHP,qBACA,cACA,gBACA,mBZjHF,yBY2GA,2DbvCE,eACA,kBCzEF,yBY+GA,2DblCE,eACA,kBayCA,+DACE,KX3HK,QW8HP,kEACE,gBACA,mBACA,qBAIJ,iDb9DA,eACA,iBa+DE,gBACA,qBACA,WACA,cACA,WZpIF,yBY8HA,iDb1DE,eACA,kBCzEF,yBYkIA,iDbrDE,eACA,kBa4DA,uDACE,WX7IO,QW8IP,qBACA,cAGF,0DACE,cACA,mBAGF,sEACE,WXxJO,QWyJP,cACA,wBAIJ,0DACE,aACA,mBAGF,kDACE,kBC5LN,MACE,kBACA,aACA,sBACA,WZoBa,QYnBb,8BAGA,eACE,gBAGF,8BACE,8BAEF,8BACE,8BAEF,wBACE,iBbOA,yBa1BJ,MAuBI,gBACA,+BAKJ,eACE,MZRK,QFwEH,eACA,iBCjEA,yBaDJ,edqEM,eACA,kBCzEF,yBaGJ,ed0EM,eACA,kBC1EF,yBaGF,4BAEI,cAGJ,qBACE,abTA,yBaQF,qBAII,iBbhBF,yBaqBJ,uBAEI,cACA,oBACA,WZ9BI,QY+BJ,mCAEA,8BACE,mBAMN,mBACE,yBAGF,kBACE,wBAIF,OACE,oCACA,gBClCF,KACE,gBAGF,UACE,SACA,iBAGF,IACE,eAWF,cACE,iBAGF,WACE,gBdlBE,+Cc6BJ,4BAEI,gBd3BA,yBcyBJ,4BAMI,eACA,gBAEA,iCACE,cACA,eAEF,0CAEE,eACA,iBAMN,cACE,iBAEF,UACE,iBAKA,0BACE,yBACA,4BAiBJ,aACE,mBAIF,yBACE,kBACA,YACA,WACA,YACA,gBAGF,OACE,oBACA,4BACA,yBACA","file":"main.css"}
\ No newline at end of file
diff --git a/pkg/server/testutils/main.go b/pkg/server/testutils/main.go
index 018b95c4..fd97c1d9 100644
--- a/pkg/server/testutils/main.go
+++ b/pkg/server/testutils/main.go
@@ -25,6 +25,9 @@ import (
"fmt"
"math/rand"
"net/http"
+ "net/url"
+ "reflect"
+ // "strconv"
"strings"
"sync"
"testing"
@@ -198,6 +201,7 @@ func MakeReq(endpoint string, method, path, data string) *http.Request {
u := fmt.Sprintf("%s%s", endpoint, path)
req, err := http.NewRequest(method, u, strings.NewReader(data))
+
if err != nil {
panic(errors.Wrap(err, "constructing http request"))
}
@@ -205,6 +209,14 @@ func MakeReq(endpoint string, method, path, data string) *http.Request {
return req
}
+// MakeFormReq makes an HTTP request and returns a response
+func MakeFormReq(endpoint, method, path string, data url.Values) *http.Request {
+ req := MakeReq(endpoint, method, path, data.Encode())
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+
+ return req
+}
+
// MustExec fails the test if the given database query has error
func MustExec(t *testing.T, db *gorm.DB, message string) {
if err := db.Error; err != nil {
@@ -271,3 +283,71 @@ func (b *MockEmailbackendImplementation) Queue(subject, from string, to []string
return nil
}
+
+// EndpointType is the type of endpoint to be tested
+type EndpointType int
+
+const (
+ // EndpointWeb represents a web endpoint returning HTML
+ EndpointWeb EndpointType = iota
+ // EndpointAPI represents an API endpoint returning JSON
+ EndpointAPI
+)
+
+type endpointTest func(t *testing.T, target EndpointType)
+
+// RunForWebAndAPI runs the given test function for web and API
+func RunForWebAndAPI(t *testing.T, name string, runTest endpointTest) {
+ t.Run(fmt.Sprintf("%s-web", name), func(t *testing.T) {
+ runTest(t, EndpointWeb)
+ })
+
+ t.Run(fmt.Sprintf("%s-api", name), func(t *testing.T) {
+ runTest(t, EndpointAPI)
+ })
+}
+
+// PayloadWrapper is a wrapper for a payload that can be converted to
+// either URL form values or JSON
+type PayloadWrapper struct {
+ Data interface{}
+}
+
+func (p PayloadWrapper) ToURLValues() url.Values {
+ values := url.Values{}
+
+ el := reflect.ValueOf(p.Data)
+ if el.Kind() == reflect.Ptr {
+ el = el.Elem()
+ }
+ iVal := el
+ typ := iVal.Type()
+ for i := 0; i < iVal.NumField(); i++ {
+ fi := typ.Field(i)
+ name := fi.Tag.Get("schema")
+ if name == "" {
+ name = fi.Name
+ }
+
+ if !iVal.Field(i).IsNil() {
+ values.Set(name, fmt.Sprint(iVal.Field(i).Elem()))
+ }
+ }
+
+ return values
+}
+
+func (p PayloadWrapper) ToJSON(t *testing.T) string {
+ b, err := json.Marshal(p.Data)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ return string(b)
+}
+
+// TrueVal is a true value
+var TrueVal = true
+
+// FalseVal is a false value
+var FalseVal = false
diff --git a/pkg/server/tmpl/data.go b/pkg/server/tmpl/data.go
index bee3d785..321c3697 100644
--- a/pkg/server/tmpl/data.go
+++ b/pkg/server/tmpl/data.go
@@ -28,7 +28,7 @@ import (
"time"
"github.com/dnote/dnote/pkg/server/database"
- "github.com/dnote/dnote/pkg/server/handlers"
+ "github.com/dnote/dnote/pkg/server/middleware"
"github.com/dnote/dnote/pkg/server/operations"
"github.com/pkg/errors"
)
@@ -52,12 +52,12 @@ type notePage struct {
}
func (a AppShell) newNotePage(r *http.Request, noteUUID string) (notePage, error) {
- user, _, err := handlers.AuthWithSession(a.DB, r, nil)
+ user, _, err := middleware.AuthWithSession(a.DB, r)
if err != nil {
return notePage{}, errors.Wrap(err, "authenticating with session")
}
- note, ok, err := operations.GetNote(a.DB, noteUUID, user)
+ note, ok, err := operations.GetNote(a.DB, noteUUID, &user)
if !ok {
return notePage{}, ErrNotFound
diff --git a/pkg/server/views/books/index.gohtml b/pkg/server/views/books/index.gohtml
new file mode 100644
index 00000000..46ebbecc
--- /dev/null
+++ b/pkg/server/views/books/index.gohtml
@@ -0,0 +1,20 @@
+{{define "yield"}}
+
+{{end}}
diff --git a/pkg/server/views/books/show.gohtml b/pkg/server/views/books/show.gohtml
new file mode 100644
index 00000000..4d84d095
--- /dev/null
+++ b/pkg/server/views/books/show.gohtml
@@ -0,0 +1,4 @@
+{{define "yield"}}
+ content
+ {{ .Note.Body }}
+{{end}}
diff --git a/pkg/server/views/data.go b/pkg/server/views/data.go
new file mode 100644
index 00000000..d36609f5
--- /dev/null
+++ b/pkg/server/views/data.go
@@ -0,0 +1,152 @@
+package views
+
+import (
+ "net/http"
+ "time"
+
+ "github.com/dnote/dnote/pkg/server/database"
+ "github.com/pkg/errors"
+)
+
+const (
+ // AlertLvlError is an alert level for error
+ AlertLvlError = "danger"
+ // AlertLvlWarning is an alert level for warning
+ AlertLvlWarning = "warning"
+ // AlertLvlInfo is an alert level for info
+ AlertLvlInfo = "info"
+ // AlertLvlSuccess is an alert level for success
+ AlertLvlSuccess = "success"
+
+ // AlertMsgGeneric is a generic message for a server error
+ AlertMsgGeneric = "Something went wrong. Please try again."
+)
+
+// Alert is used to render Bootstrap Alert messages in templates
+type Alert struct {
+ Level string
+ Message string
+}
+
+// Data is the top level structure that views expect data to come in.
+type Data struct {
+ Alert *Alert
+ // CSRF template.HTML
+ User *database.User
+ Account *database.Account
+ Yield map[string]interface{}
+}
+
+func getErrMessage(err error) string {
+ if pErr, ok := err.(PublicError); ok {
+ return pErr.Public()
+ }
+
+ return AlertMsgGeneric
+}
+
+// PutAlert puts an alert in the given data.
+func (d *Data) PutAlert(alert Alert, alertInYield bool) {
+ if alertInYield {
+ if d.Yield == nil {
+ d.Yield = map[string]interface{}{}
+ }
+ d.Yield["Alert"] = &alert
+ } else {
+ d.Alert = &alert
+ }
+}
+
+// SetAlert sets alert in the given data for given error.
+func (d *Data) SetAlert(err error, alertInYield bool) {
+ errC := errors.Cause(err)
+
+ var alert Alert
+ if pErr, ok := errC.(PublicError); ok {
+ alert = Alert{
+ Level: AlertLvlError,
+ Message: pErr.Public(),
+ }
+ } else {
+ alert = Alert{
+ Level: AlertLvlError,
+ Message: AlertMsgGeneric,
+ }
+ }
+
+ d.PutAlert(alert, alertInYield)
+}
+
+// AlertError returns a new error alert using the given message.
+func (d *Data) AlertError(msg string) {
+ d.Alert = &Alert{
+ Level: AlertLvlError,
+ Message: msg,
+ }
+}
+
+func persistAlert(w http.ResponseWriter, alert Alert) {
+ expiresAt := time.Now().Add(5 * time.Minute)
+ lvl := http.Cookie{
+ Name: "alert_level",
+ Value: alert.Level,
+ Expires: expiresAt,
+ Path: "/",
+ HttpOnly: true,
+ }
+ msg := http.Cookie{
+ Name: "alert_message",
+ Value: alert.Message,
+ Expires: expiresAt,
+ Path: "/",
+ HttpOnly: true,
+ }
+ http.SetCookie(w, &lvl)
+ http.SetCookie(w, &msg)
+}
+
+func clearAlert(w http.ResponseWriter) {
+ lvl := http.Cookie{
+ Name: "alert_level",
+ Value: "",
+ Expires: time.Now(),
+ HttpOnly: true,
+ }
+ msg := http.Cookie{
+ Name: "alert_message",
+ Value: "",
+ Expires: time.Now(),
+ HttpOnly: true,
+ }
+ http.SetCookie(w, &lvl)
+ http.SetCookie(w, &msg)
+}
+
+func getAlert(r *http.Request) *Alert {
+ lvl, err := r.Cookie("alert_level")
+ if err != nil {
+ return nil
+ }
+ msg, err := r.Cookie("alert_message")
+ if err != nil {
+ return nil
+ }
+ alert := Alert{
+ Level: lvl.Value,
+ Message: msg.Value,
+ }
+ return &alert
+}
+
+// RedirectAlert redirects to a URL after persisting the provided alert data
+// into a cookie so that it can be displayed when the page is rendered.
+func RedirectAlert(w http.ResponseWriter, r *http.Request, urlStr string, code int, alert Alert) {
+ persistAlert(w, alert)
+ http.Redirect(w, r, urlStr, code)
+}
+
+// PublicError is an error meant to be displayed to the public
+type PublicError interface {
+ error
+ Public() string
+}
diff --git a/pkg/server/views/helpers.go b/pkg/server/views/helpers.go
new file mode 100644
index 00000000..ee82eb4f
--- /dev/null
+++ b/pkg/server/views/helpers.go
@@ -0,0 +1,176 @@
+package views
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/dnote/dnote/pkg/server/app"
+ "github.com/dnote/dnote/pkg/server/buildinfo"
+ "github.com/pkg/errors"
+ "html/template"
+)
+
+func initHelpers(c Config, a *app.App) template.FuncMap {
+ ctx := newViewCtx(c)
+
+ ret := template.FuncMap{
+ "csrfField": ctx.csrfField,
+ "css": ctx.css,
+ "js": ctx.js,
+ "title": ctx.title,
+ "headerTemplate": ctx.headerTemplate,
+ "rootURL": ctx.rootURL,
+ "getFullMonthName": ctx.getFullMonthName,
+ "toDateTime": ctx.toDateTime,
+ "excerpt": ctx.excerpt,
+ "timeAgo": ctx.timeAgo,
+ "timeFormat": ctx.timeFormat,
+ "toISOString": ctx.toISOString,
+ "dict": ctx.dict,
+ "defaultValue": ctx.defaultValue,
+ "add": ctx.add,
+ "assetBaseURL": func() string {
+ return a.Config.AssetBaseURL
+ },
+ }
+
+ // extend with helpers that are defined specific to a view
+ if c.HelperFuncs != nil {
+ for k, v := range c.HelperFuncs {
+ ret[k] = v
+ }
+ }
+
+ return ret
+}
+
+func (v viewCtx) csrfField() (template.HTML, error) {
+ return "", errors.New("csrfField is not implemented")
+}
+
+func (v viewCtx) css() []string {
+ return strings.Split(buildinfo.CSSFiles, ",")
+}
+
+func (v viewCtx) js() []string {
+ return strings.Split(buildinfo.JSFiles, ",")
+}
+
+func (v viewCtx) title() string {
+ if v.Config.Title != "" {
+ return fmt.Sprintf("%s | %s", v.Config.Title, siteTitle)
+ }
+
+ return siteTitle
+}
+
+func (v viewCtx) headerTemplate() string {
+ return v.Config.HeaderTemplate
+}
+
+func (v viewCtx) toDateTime(year, month int) string {
+ sb := strings.Builder{}
+
+ sb.WriteString(strconv.Itoa(year))
+ sb.WriteString("-")
+
+ if month < 10 {
+ sb.WriteString("0")
+ sb.WriteString(strconv.Itoa(month))
+ } else {
+ sb.WriteString(strconv.Itoa(month))
+ }
+
+ return sb.String()
+}
+
+func (v viewCtx) getFullMonthName(month int) string {
+ return time.Month(month).String()
+}
+
+func (v viewCtx) rootURL() string {
+ return buildinfo.RootURL
+}
+
+func min(a, b int) int {
+ if a < b {
+ return a
+ }
+
+ return b
+}
+
+func max(a, b int) int {
+ if a > b {
+ return a
+ }
+
+ return b
+}
+
+// excerpt trims the given string up to the last word that makes the string
+// exceed the maxLength, and attaches ellipses at the end. If the string is
+// shorter than the given maxLength, it returns the original string.
+func (v viewCtx) excerpt(s string, maxLength int) string {
+ if len(s) < maxLength {
+ return s
+ }
+
+ ret := s[0:maxLength]
+ ret = s[0:min(len(ret), max(0, strings.LastIndex(ret, " ")))]
+ ret += "..."
+
+ return ret
+}
+
+func (v viewCtx) timeFormat(t time.Time, format string) string {
+ return t.Format(format)
+}
+
+func (v viewCtx) timeAgo(t time.Time) string {
+ now := v.Clock.Now()
+ diff := relativeTimeDiff(now, t)
+
+ if diff.tense == "past" {
+ return fmt.Sprintf("%s ago", diff.text)
+ }
+
+ if diff.tense == "future" {
+ return fmt.Sprintf("in %s", diff.text)
+ }
+
+ return diff.text
+}
+
+func (v viewCtx) toISOString(t time.Time) string {
+ return t.Format(time.RFC3339)
+}
+
+func (v viewCtx) dict(values ...interface{}) (map[string]interface{}, error) {
+ if len(values)%2 != 0 {
+ return nil, errors.New("invalid dict call")
+ }
+ dict := make(map[string]interface{}, len(values)/2)
+ for i := 0; i < len(values); i += 2 {
+ key, ok := values[i].(string)
+ if !ok {
+ return nil, errors.New("dict keys must be strings")
+ }
+ dict[key] = values[i+1]
+ }
+ return dict, nil
+}
+
+func (v viewCtx) defaultValue(value, fallback interface{}) interface{} {
+ if value == nil {
+ return fallback
+ }
+
+ return value
+}
+
+func (v viewCtx) add(a, b int) interface{} {
+ return a + b
+}
diff --git a/pkg/server/views/helpers_test.go b/pkg/server/views/helpers_test.go
new file mode 100644
index 00000000..d469a9fc
--- /dev/null
+++ b/pkg/server/views/helpers_test.go
@@ -0,0 +1,183 @@
+package views
+
+import (
+ "fmt"
+ "testing"
+ "time"
+
+ "github.com/dnote/dnote/pkg/assert"
+)
+
+func TestToDateTime(t *testing.T) {
+ testCases := []struct {
+ year int
+ month int
+ expected string
+ }{
+ {
+ year: 2010,
+ month: 10,
+ expected: "2010-10",
+ },
+ {
+ year: 2010,
+ month: 8,
+ expected: "2010-08",
+ },
+ }
+
+ ctx := viewCtx{}
+
+ for _, tc := range testCases {
+ got := ctx.toDateTime(tc.year, tc.month)
+
+ assert.Equal(t, got, tc.expected, "result mismatch")
+ }
+}
+
+func TestGetFullMonthName(t *testing.T) {
+ testCases := []struct {
+ input int
+ expected string
+ }{
+ {
+ input: 1,
+ expected: "January",
+ },
+ {
+ input: 12,
+ expected: "December",
+ },
+ }
+
+ ctx := viewCtx{}
+
+ for _, tc := range testCases {
+ got := ctx.getFullMonthName(tc.input)
+
+ assert.Equal(t, got, tc.expected, "result mismatch")
+ }
+}
+
+func TestExcerpt(t *testing.T) {
+ testCases := []struct {
+ str string
+ maxLength int
+ expected string
+ }{
+ {
+ str: "hello world",
+ maxLength: 5,
+ expected: "...",
+ },
+ {
+ str: "hello world",
+ maxLength: 1,
+ expected: "...",
+ },
+ {
+ str: "hello world",
+ maxLength: 7,
+ expected: "hello...",
+ },
+ {
+ str: "foo bar baz",
+ maxLength: 9,
+ expected: "foo bar...",
+ },
+ {
+ str: "foo",
+ maxLength: 4,
+ expected: "foo",
+ },
+ }
+
+ ctx := viewCtx{}
+
+ for idx, tc := range testCases {
+ got := ctx.excerpt(tc.str, tc.maxLength)
+ assert.Equal(t, got, tc.expected, fmt.Sprintf("result mismatch for case %d", idx))
+ }
+}
+
+func TestTimeAgo(t *testing.T) {
+ now := time.Now()
+
+ testCases := []struct {
+ input time.Time
+ expected string
+ }{
+ {
+ input: now.Add(-2 * time.Hour),
+ expected: "2 hours ago",
+ },
+ {
+ input: now.Add(-2*time.Hour - 59*time.Minute),
+ expected: "2 hours ago",
+ },
+ {
+ input: now.Add(-23 * time.Hour),
+ expected: "23 hours ago",
+ },
+ {
+ input: now.Add(-23*time.Hour - 59*time.Minute),
+ expected: "23 hours ago",
+ },
+ {
+ input: now.Add(-24 * time.Hour),
+ expected: "1 day ago",
+ },
+ {
+ input: now.Add(-47 * time.Hour),
+ expected: "1 day ago",
+ },
+ {
+ input: now.Add(-48 * time.Hour),
+ expected: "2 days ago",
+ },
+
+ {
+ input: now.Add(-24 * time.Hour * 7),
+ expected: "1 week ago",
+ },
+ {
+ input: now.Add(-24 * time.Hour * 7 * 2),
+ expected: "2 weeks ago",
+ },
+
+ {
+ input: now.Add(-24 * time.Hour * 7 * 4),
+ expected: "1 month ago",
+ },
+ {
+ input: now.Add(-24 * time.Hour * 7 * 7),
+ expected: "1 month ago",
+ },
+ {
+ input: now.Add(-24 * time.Hour * 7 * 8),
+ expected: "2 months ago",
+ },
+
+ {
+ input: now.Add(-24 * time.Hour * 7 * 52),
+ expected: "1 year ago",
+ },
+ {
+ input: now.Add(-24 * time.Hour * 7 * 55),
+ expected: "1 year ago",
+ },
+ {
+ input: now.Add(-24 * time.Hour * 7 * 52 * 2),
+ expected: "2 years ago",
+ },
+ }
+
+ ctx := newViewCtx(Config{})
+
+ for _, tc := range testCases {
+ t.Run(fmt.Sprintf("input %s", tc.input.String()), func(t *testing.T) {
+ got := ctx.timeAgo(tc.input)
+ assert.Equal(t, got, tc.expected, "result mismatch")
+ })
+ }
+}
diff --git a/pkg/server/views/icons/book.gohtml b/pkg/server/views/icons/book.gohtml
new file mode 100644
index 00000000..a04a3e68
--- /dev/null
+++ b/pkg/server/views/icons/book.gohtml
@@ -0,0 +1,17 @@
+{{define "book"}}
+
+{{end}}
diff --git a/pkg/server/views/icons/caret.gohtml b/pkg/server/views/icons/caret.gohtml
new file mode 100644
index 00000000..0a9f60a0
--- /dev/null
+++ b/pkg/server/views/icons/caret.gohtml
@@ -0,0 +1,26 @@
+{{define "caret"}}
+
+{{end}}
diff --git a/pkg/server/views/icons/lock.gohtml b/pkg/server/views/icons/lock.gohtml
new file mode 100644
index 00000000..f43c202a
--- /dev/null
+++ b/pkg/server/views/icons/lock.gohtml
@@ -0,0 +1,10 @@
+{{define "lockIcon"}}
+
+{{end}}
diff --git a/pkg/server/views/icons/logo.gohtml b/pkg/server/views/icons/logo.gohtml
new file mode 100644
index 00000000..fac485e5
--- /dev/null
+++ b/pkg/server/views/icons/logo.gohtml
@@ -0,0 +1,14 @@
+{{define "logo"}}
+
+{{end}}
diff --git a/pkg/server/views/icons/logo_with_text.gohtml b/pkg/server/views/icons/logo_with_text.gohtml
new file mode 100644
index 00000000..7f9d7c66
--- /dev/null
+++ b/pkg/server/views/icons/logo_with_text.gohtml
@@ -0,0 +1,26 @@
+{{define "logoWithText"}}
+
+{{end}}
diff --git a/pkg/server/views/layouts/alert.gohtml b/pkg/server/views/layouts/alert.gohtml
new file mode 100644
index 00000000..661753f9
--- /dev/null
+++ b/pkg/server/views/layouts/alert.gohtml
@@ -0,0 +1,9 @@
+{{define "alert"}}
+{{if .}}
+
+{{end}}
+{{end}}
diff --git a/pkg/server/views/layouts/base.gohtml b/pkg/server/views/layouts/base.gohtml
new file mode 100644
index 00000000..fe6ace0b
--- /dev/null
+++ b/pkg/server/views/layouts/base.gohtml
@@ -0,0 +1,40 @@
+{{define "base"}}
+
+
+
+
+
+ {{ title }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{template "css" .}}
+
+
+
+ {{template "header" .}}
+
+ {{template "alert" .Alert}}
+
+
+ {{template "yield" .Yield}}
+
+
+ {{template "js" .}}
+
+
+{{end}}
diff --git a/pkg/server/views/layouts/css.gohtml b/pkg/server/views/layouts/css.gohtml
new file mode 100644
index 00000000..4d248a56
--- /dev/null
+++ b/pkg/server/views/layouts/css.gohtml
@@ -0,0 +1,5 @@
+{{define "css"}}
+ {{range css}}
+
+ {{end}}
+{{end}}
diff --git a/pkg/server/views/layouts/header.gohtml b/pkg/server/views/layouts/header.gohtml
new file mode 100644
index 00000000..6d8451e7
--- /dev/null
+++ b/pkg/server/views/layouts/header.gohtml
@@ -0,0 +1,7 @@
+{{define "header"}}
+
+{{if eq headerTemplate "navbar"}}
+ {{ template "navbar" . }}
+{{end}}
+
+{{end}}
diff --git a/pkg/server/views/layouts/js.gohtml b/pkg/server/views/layouts/js.gohtml
new file mode 100644
index 00000000..50b23673
--- /dev/null
+++ b/pkg/server/views/layouts/js.gohtml
@@ -0,0 +1,5 @@
+{{define "js"}}
+ {{range js}}
+
+ {{end}}
+{{end}}
diff --git a/pkg/server/views/layouts/navbar.gohtml b/pkg/server/views/layouts/navbar.gohtml
new file mode 100644
index 00000000..ea7c026f
--- /dev/null
+++ b/pkg/server/views/layouts/navbar.gohtml
@@ -0,0 +1,68 @@
+{{define "navbar"}}
+
+{{end}}
+
+{{define "accountDropdown"}}
+
+
+
+
+
+
+
+ -
+ Settings
+
+ -
+ {{template "logoutForm"}}
+
+
+
+
+{{end}}
+
+{{define "logoutForm"}}
+
+{{end}}
diff --git a/pkg/server/views/notes/index.gohtml b/pkg/server/views/notes/index.gohtml
new file mode 100644
index 00000000..168430f5
--- /dev/null
+++ b/pkg/server/views/notes/index.gohtml
@@ -0,0 +1,91 @@
+{{define "yield"}}
+
+
Notes
+
+ {{template "pageToolbar" dict "data" . "class" "toolbar"}}
+
+
+ {{if eq (len .NoteGroups) 0 }}
+
No notes found.
+ {{end}}
+
+ {{range .NoteGroups}}
+ {{template "noteGroup" .}}
+ {{end}}
+
+
+{{end}}
+
+{{define "noteGroup"}}
+
+
+
+
+ {{range .Data}}
+ {{template "noteItem" .}}
+ {{end}}
+
+
+{{end}}
+
+{{define "noteItem"}}
+
+
+
+
+
+
+ {{ excerpt .Body 160 }}
+
+
+
+
+{{end}}
+
+{{define "pageToolbarContent"}}
+
+{{end}}
+
+{{define "pager"}}
+
+{{$ariaLabel := ""}}
+{{if eq .direction "left"}}
+ {{$ariaLabel = "Previous page"}}
+{{else}}
+ {{$ariaLabel = "Next page"}}
+{{end}}
+
+{{if .disabled}}
+
+ {{template "caret" dict "direction" .direction "stroke" "gray"}}
+
+{{else}}
+
+ {{template "caret" dict "direction" .direction "stroke" "black"}}
+
+{{end}}
+{{end}}
diff --git a/pkg/server/views/notes/show.gohtml b/pkg/server/views/notes/show.gohtml
new file mode 100644
index 00000000..7051bee4
--- /dev/null
+++ b/pkg/server/views/notes/show.gohtml
@@ -0,0 +1,33 @@
+{{define "yield"}}
+
+{{end}}
diff --git a/pkg/server/views/partials/page_toolbar.gohtml b/pkg/server/views/partials/page_toolbar.gohtml
new file mode 100644
index 00000000..4d1abdfd
--- /dev/null
+++ b/pkg/server/views/partials/page_toolbar.gohtml
@@ -0,0 +1,5 @@
+{{define "pageToolbar"}}
+
+ {{template "pageToolbarContent" .data}}
+
+{{end}}
diff --git a/pkg/server/views/partials/settings_sidebar.gohtml b/pkg/server/views/partials/settings_sidebar.gohtml
new file mode 100644
index 00000000..0d3e5edf
--- /dev/null
+++ b/pkg/server/views/partials/settings_sidebar.gohtml
@@ -0,0 +1,23 @@
+{{define "settingsSidebar"}}
+
+{{end}}
diff --git a/pkg/server/views/partials/time.gohtml b/pkg/server/views/partials/time.gohtml
new file mode 100644
index 00000000..b05b3a86
--- /dev/null
+++ b/pkg/server/views/partials/time.gohtml
@@ -0,0 +1,13 @@
+{{define "time"}}
+
+{{$mobileText := defaultValue .mobileText .text}}
+
+
+
+
+{{end}}
diff --git a/pkg/server/views/static/not_found.gohtml b/pkg/server/views/static/not_found.gohtml
new file mode 100644
index 00000000..baef1f4a
--- /dev/null
+++ b/pkg/server/views/static/not_found.gohtml
@@ -0,0 +1,3 @@
+{{define "yield"}}
+Page not found
+{{end}}
diff --git a/pkg/server/views/time.go b/pkg/server/views/time.go
new file mode 100644
index 00000000..dad46c46
--- /dev/null
+++ b/pkg/server/views/time.go
@@ -0,0 +1,103 @@
+package views
+
+import (
+ "fmt"
+ "time"
+)
+
+type timeDiff struct {
+ text string
+ tense string
+}
+
+func pluralize(singular string, count int) string {
+ var noun string
+ if count == 1 {
+ noun = singular
+ } else {
+ noun = singular + "s"
+ }
+
+ return noun
+}
+
+func abs(num int64) int64 {
+ if num < 0 {
+ return -num
+ }
+
+ return num
+}
+
+var (
+ DAY = 24 * time.Hour.Milliseconds()
+ WEEK = 7 * DAY
+)
+
+func getTimeDiffText(interval int64, noun string) string {
+ return fmt.Sprintf("%d %s", interval, pluralize(noun, int(interval)))
+}
+
+func relativeTimeDiff(t1, t2 time.Time) timeDiff {
+ diff := t1.Sub(t2)
+ ts := abs(diff.Milliseconds())
+
+ var tense string
+ if diff > 0 {
+ tense = "past"
+ } else {
+ tense = "future"
+ }
+
+ interval := ts / (52 * WEEK)
+ if interval >= 1 {
+ return timeDiff{
+ text: getTimeDiffText(interval, "year"),
+ tense: tense,
+ }
+ }
+
+ interval = ts / (4 * WEEK)
+ if interval >= 1 {
+ return timeDiff{
+ text: getTimeDiffText(interval, "month"),
+ tense: tense,
+ }
+ }
+
+ interval = ts / WEEK
+ if interval >= 1 {
+ return timeDiff{
+ text: getTimeDiffText(interval, "week"),
+ tense: tense,
+ }
+ }
+
+ interval = ts / DAY
+ if interval >= 1 {
+ return timeDiff{
+ text: getTimeDiffText(interval, "day"),
+ tense: tense,
+ }
+ }
+
+ interval = ts / time.Hour.Milliseconds()
+ if interval >= 1 {
+ return timeDiff{
+ text: getTimeDiffText(interval, "hour"),
+ tense: tense,
+ }
+ }
+
+ interval = ts / time.Minute.Milliseconds()
+ if interval >= 1 {
+ return timeDiff{
+ text: getTimeDiffText(interval, "minute"),
+ tense: tense,
+ }
+ }
+
+ return timeDiff{
+ text: "Just now",
+ }
+}
diff --git a/pkg/server/views/users/email_verification.gohtml b/pkg/server/views/users/email_verification.gohtml
new file mode 100644
index 00000000..969688a4
--- /dev/null
+++ b/pkg/server/views/users/email_verification.gohtml
@@ -0,0 +1,2 @@
+{{define "yield"}}
+{{end}}
diff --git a/pkg/server/views/users/login.gohtml b/pkg/server/views/users/login.gohtml
new file mode 100644
index 00000000..1ee81737
--- /dev/null
+++ b/pkg/server/views/users/login.gohtml
@@ -0,0 +1,76 @@
+{{define "yield"}}
+
+
+
+ {{template "logo" .}}
+
+
+
Sign in to Dnote
+
+
+ {{if .Referrer}}
+
+ Please sign in to continue to that page.
+
+ {{end}}
+
+
+ {{if .Alert}}
+
+ {{.Alert.Message}}
+
+ {{end}}
+
+ {{template "loginForm" .}}
+
+
+
+
+
+
+{{end}}
+
+{{define "loginForm"}}
+
+{{end}}
diff --git a/pkg/server/views/users/new.gohtml b/pkg/server/views/users/new.gohtml
new file mode 100644
index 00000000..e2d703bb
--- /dev/null
+++ b/pkg/server/views/users/new.gohtml
@@ -0,0 +1,86 @@
+{{define "yield"}}
+
+
+
+ {{template "logo" .}}
+
+
+
Join Dnote
+
+
+ {{if .Referrer}}
+
+ Please join to continue.
+
+ {{end}}
+
+
+ {{if .Alert}}
+
+ {{.Alert.Message}}
+
+ {{end}}
+
+ {{template "signupForm" .}}
+
+
+
+
+
+
+{{end}}
+
+{{define "signupForm"}}
+
+{{end}}
diff --git a/pkg/server/views/users/password_reset.gohtml b/pkg/server/views/users/password_reset.gohtml
new file mode 100644
index 00000000..6d7201d5
--- /dev/null
+++ b/pkg/server/views/users/password_reset.gohtml
@@ -0,0 +1,52 @@
+{{define "yield"}}
+
+
+
+ {{template "logo" .}}
+
+
Reset Password
+
+
+
+ {{if .Alert}}
+
+ {{.Alert.Message}}
+
+ {{end}}
+
+ {{template "passwordResetForm" .}}
+
+
+
+
+
+
+{{end}}
+
+{{define "passwordResetForm"}}
+
+{{end}}
diff --git a/pkg/server/views/users/password_reset_confirm.gohtml b/pkg/server/views/users/password_reset_confirm.gohtml
new file mode 100644
index 00000000..ddbb2ac7
--- /dev/null
+++ b/pkg/server/views/users/password_reset_confirm.gohtml
@@ -0,0 +1,66 @@
+{{define "yield"}}
+
+
+
+ {{template "logo" .}}
+
+
Reset Password
+
+
+
+
+ Password must be at least 8 characters long.
+
+
+
+ {{if .Alert}}
+
+ {{.Alert.Message}}
+
+ {{end}}
+
+ {{template "passwordResetConfirmForm" .}}
+
+
+
+
+{{end}}
+
+{{define "passwordResetConfirmForm"}}
+
+{{end}}
diff --git a/pkg/server/views/users/settings.gohtml b/pkg/server/views/users/settings.gohtml
new file mode 100644
index 00000000..a2af6649
--- /dev/null
+++ b/pkg/server/views/users/settings.gohtml
@@ -0,0 +1,241 @@
+{{define "yield"}}
+
+
+
+
+
+
+ {{template "settingsSidebar" .}}
+
+
+
+
+ {{if ne .Standalone "true"}}
+ {{template "planSection" .}}
+ {{end}}
+ {{template "emailSection" .}}
+ {{template "passwordSection" .}}
+
+
+
+
+
+{{end}}
+
+{{define "emailForm"}}
+
+{{end}}
+
+{{define "passwordChangeForm"}}
+
+{{end}}
+
+{{define "emailSection"}}
+
+ Email
+
+
+
+
+
Current Email
+
+
+
+ {{.Email}}
+
+
+
+
+
+
+
+
Email Verified
+
+
+
+ {{ if eq true false }} b{{end}}
+
+ {{if .EmailVerified}}
+ Yes
+ {{else}}
+ No
+
+
+ {{end}}
+
+
+
+
+
+
+
+
+ {{template "emailForm" .}}
+
+
+
+{{end}}
+
+{{define "passwordSection"}}
+
+ Password
+
+
+
+
+
Change Password
+
+ Set a unique password to protect your data.
+
+
+
+
+
+ {{template "passwordChangeForm" .}}
+
+
+
+{{end}}
+
+{{define "planSection"}}
+
+ Plan
+
+
+
+
+
Dnote Pro
+
+ Fully hosted and managed Dnote for you.
+
+
+
+
+ {{if .Cloud}}
+ Yes
+ {{else}}
+
+ Unlock
+
+ {{end}}
+
+
+
+
+
+{{end}}
diff --git a/pkg/server/views/users/settings_about.gohtml b/pkg/server/views/users/settings_about.gohtml
new file mode 100644
index 00000000..bba9a8f0
--- /dev/null
+++ b/pkg/server/views/users/settings_about.gohtml
@@ -0,0 +1,57 @@
+{{define "yield"}}
+
+
+
+
+
+
+ {{template "settingsSidebar" .}}
+
+
+
+
+
+ Software
+
+
+
+
+
Version
+
+
+
+ {{.Version}}
+
+
+
+
+ {{if ne .Standalone "true"}}
+
+ {{else}}
+
+ {{end}}
+
+
+
+
+
+
+{{end}}
diff --git a/pkg/server/views/view.go b/pkg/server/views/view.go
new file mode 100644
index 00000000..db139c3b
--- /dev/null
+++ b/pkg/server/views/view.go
@@ -0,0 +1,203 @@
+package views
+
+import (
+ "bytes"
+ "fmt"
+ "html/template"
+ "io"
+ "net/http"
+ "path/filepath"
+
+ "github.com/dnote/dnote/pkg/clock"
+ "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/log"
+ "github.com/gorilla/csrf"
+ "github.com/pkg/errors"
+)
+
+const (
+ // templateExt is the template extension
+ templateExt string = ".gohtml"
+)
+
+const (
+ siteTitle = "Dnote"
+)
+
+const (
+ ServerErrorPageFileKey = "500"
+)
+
+// Config is a view config
+type Config struct {
+ Title string
+ Layout string
+ HeaderTemplate string
+ HelperFuncs map[string]interface{}
+ AlertInBody bool
+ Clock clock.Clock
+}
+
+type viewCtx struct {
+ Clock clock.Clock
+ Config Config
+}
+
+func newViewCtx(c Config) viewCtx {
+ return viewCtx{
+ Clock: c.getClock(),
+ Config: c,
+ }
+}
+
+func (c Config) getLayout() string {
+ if c.Layout == "" {
+ return "base"
+ }
+
+ return c.Layout
+}
+
+func (c Config) getClock() clock.Clock {
+ if c.Clock != nil {
+ return c.Clock
+ }
+
+ return clock.New()
+}
+
+// NewView returns a new view by parsing the given layout and files
+func NewView(baseDir string, app *app.App, viewConfig Config, files ...string) *View {
+ addTemplatePath(baseDir, files)
+ addTemplateExt(files)
+
+ files = append(files, iconFiles(baseDir)...)
+ files = append(files, layoutFiles(baseDir)...)
+ files = append(files, partialFiles(baseDir)...)
+
+ viewHelpers := initHelpers(viewConfig, app)
+ t := template.New(viewConfig.Title).Funcs(viewHelpers)
+
+ t, err := t.ParseFiles(files...)
+ if err != nil {
+ panic(errors.Wrap(err, "instantiating view"))
+ }
+
+ return &View{
+ Template: t,
+ Layout: viewConfig.getLayout(),
+ AlertInBody: viewConfig.AlertInBody,
+ Files: app.Files,
+ }
+}
+
+// View holds the information about a view
+type View struct {
+ Template *template.Template
+ Layout string
+ // AlertInBody specifies if alert should be set in the body instead of the header
+ AlertInBody bool
+ Files map[string][]byte
+}
+
+func (v *View) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ v.Render(w, r, nil, http.StatusOK)
+}
+
+// Render is used to render the view with the predefined layout
+func (v *View) Render(w http.ResponseWriter, r *http.Request, data *Data, statusCode int) {
+ w.Header().Set("Content-Type", "text/html")
+
+ var vd Data
+ if data != nil {
+ vd = *data
+ }
+
+ if alert := getAlert(r); alert != nil {
+ vd.PutAlert(*alert, v.AlertInBody)
+ clearAlert(w)
+ }
+
+ vd.User = context.User(r.Context())
+ vd.Account = context.Account(r.Context())
+
+ // Put user data in Yield
+ if vd.Yield == nil {
+ vd.Yield = map[string]interface{}{}
+ }
+ if vd.Account != nil {
+ vd.Yield["Email"] = vd.Account.Email.String
+ vd.Yield["EmailVerified"] = vd.Account.EmailVerified
+ vd.Yield["EmailVerified"] = vd.Account.EmailVerified
+ }
+ if vd.User != nil {
+ vd.Yield["Cloud"] = vd.User.Cloud
+ }
+ vd.Yield["CurrentPath"] = r.URL.Path
+ vd.Yield["Standalone"] = buildinfo.Standalone
+
+ var buf bytes.Buffer
+ csrfField := csrf.TemplateField(r)
+ tpl := v.Template.Funcs(template.FuncMap{
+ "csrfField": func() template.HTML {
+ return csrfField
+ },
+ })
+
+ if err := tpl.ExecuteTemplate(&buf, v.Layout, vd); err != nil {
+ log.ErrorWrap(err, fmt.Sprintf("executing template for URI '%s'", r.RequestURI))
+ w.WriteHeader(http.StatusInternalServerError)
+ w.Write(v.Files[ServerErrorPageFileKey])
+ return
+ }
+
+ w.WriteHeader(statusCode)
+ io.Copy(w, &buf)
+}
+
+func getFiles(pattern string) []string {
+ files, err := filepath.Glob(pattern)
+ if err != nil {
+ panic(err)
+ }
+
+ return files
+}
+
+// layoutFiles returns a slice of strings representing
+// the layout files used in our application.
+func layoutFiles(baseDir string) []string {
+ return getFiles(fmt.Sprintf("%s/layouts/*%s", baseDir, templateExt))
+}
+
+// iconFiles returns a slice of strings representing
+// the icon files used in our application.
+func iconFiles(baseDir string) []string {
+ return getFiles(fmt.Sprintf("%s/icons/*%s", baseDir, templateExt))
+}
+
+func partialFiles(baseDir string) []string {
+ return getFiles(fmt.Sprintf("%s/partials/*%s", baseDir, templateExt))
+}
+
+// addTemplatePath takes in a slice of strings
+// representing file paths for templates.
+func addTemplatePath(baseDir string, files []string) {
+ for i, f := range files {
+ files[i] = fmt.Sprintf("%s/%s", baseDir, f)
+ }
+}
+
+// addTemplateExt takes in a slice of strings
+// representing file paths for templates and it appends
+// the templateExt extension to each string in the slice
+//
+// Eg the input {"home"} would result in the output
+// {"home.gohtml"} if templateExt == ".gohtml"
+func addTemplateExt(files []string) {
+ for i, f := range files {
+ files[i] = f + templateExt
+ }
+}
diff --git a/pkg/server/web/handlers.go b/pkg/server/web/handlers.go
index 854dbbb7..3eeeb6dc 100644
--- a/pkg/server/web/handlers.go
+++ b/pkg/server/web/handlers.go
@@ -22,7 +22,7 @@ package web
import (
"net/http"
- "github.com/dnote/dnote/pkg/server/handlers"
+ "github.com/dnote/dnote/pkg/server/middleware"
"github.com/dnote/dnote/pkg/server/tmpl"
"github.com/jinzhu/gorm"
"github.com/pkg/errors"
@@ -106,9 +106,9 @@ func getRootHandler(c Context) http.HandlerFunc {
buf, err := appShell.Execute(r)
if err != nil {
if errors.Cause(err) == tmpl.ErrNotFound {
- handlers.RespondNotFound(w)
+ middleware.RespondNotFound(w)
} else {
- handlers.DoError(w, "executing app shell", err, http.StatusInternalServerError)
+ middleware.DoError(w, "executing app shell", err, http.StatusInternalServerError)
}
return
}
diff --git a/pkg/watcher/main.go b/pkg/watcher/main.go
index 041fb774..4b2b4720 100644
--- a/pkg/watcher/main.go
+++ b/pkg/watcher/main.go
@@ -19,11 +19,13 @@
package main
import (
+ "encoding/csv"
"flag"
"log"
"os"
"os/exec"
"os/signal"
+ "regexp"
"strings"
"syscall"
"time"
@@ -32,6 +34,23 @@ import (
"github.com/radovskyb/watcher"
)
+// splitCommandParts splits the given commad string at space, except
+// when inside a double quotation mark.
+func splitCommandParts(cmd string) []string {
+ re := regexp.MustCompile(`\r?\n`)
+ s := re.ReplaceAllString(cmd, " ")
+
+ r := csv.NewReader(strings.NewReader(s))
+ r.Comma = ' '
+
+ fields, err := r.Read()
+ if err != nil {
+ panic(err)
+ }
+
+ return fields
+}
+
func command(binary string, args []string, entryPoint string) *exec.Cmd {
cmd := exec.Command(binary, args...)
@@ -52,7 +71,7 @@ func command(binary string, args []string, entryPoint string) *exec.Cmd {
}
func execCmd(task string, watchDir string) *exec.Cmd {
- parts := strings.Fields(task)
+ parts := splitCommandParts(task)
return command(parts[0], parts[1:], watchDir)
}
diff --git a/scripts/server/test-local.sh b/scripts/server/test-local.sh
index 9f99e08a..ce50d7d6 100755
--- a/scripts/server/test-local.sh
+++ b/scripts/server/test-local.sh
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# shellcheck disable=SC1090
# test-local.sh runs api tests using local setting
-set -eux
+set -ex
dir=$(dirname "${BASH_SOURCE[0]}")
@@ -9,4 +9,4 @@ set -a
source "$dir/../../pkg/server/.env.test"
set +a
-"$dir/test.sh"
+"$dir/test.sh" "$1"
diff --git a/scripts/server/test.sh b/scripts/server/test.sh
index 89d51a79..a2d9ac02 100755
--- a/scripts/server/test.sh
+++ b/scripts/server/test.sh
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# test.sh runs server tests. It is to be invoked by other scripts that set
# appropriate env vars.
-set -eux
+set -ex
dir=$(realpath "$(dirname "${BASH_SOURCE[0]}")")
pushd "$dir/../../pkg/server"
@@ -10,7 +10,11 @@ emailTemplateDir=$(realpath "$dir/../../pkg/server/mailer/templates/src")
export DNOTE_TEST_EMAIL_TEMPLATE_DIR="$emailTemplateDir"
function run_test {
- go test ./... -cover -p 1
+ if [ -z "$1" ]; then
+ go test ./... -cover -p 1
+ else
+ go test -run "$1" -cover -p 1
+ fi
}
if [ "${WATCH-false}" == true ]; then
@@ -18,7 +22,7 @@ if [ "${WATCH-false}" == true ]; then
while inotifywait --exclude .swp -e modify -r .; do run_test; done;
set -e
else
- run_test
+ run_test "$1"
fi
popd
diff --git a/scripts/vagrant/install_utils.sh b/scripts/vagrant/install_utils.sh
index 1c06d413..46322005 100755
--- a/scripts/vagrant/install_utils.sh
+++ b/scripts/vagrant/install_utils.sh
@@ -9,3 +9,10 @@ wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | sudo apt-ke
echo 'deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main' | sudo tee /etc/apt/sources.list.d/google-chrome.list
sudo apt-get -y update
sudo apt-get install -y google-chrome-stable
+
+# Install dart-sass
+dart_version=1.34.1
+dart_tarball="dart-sass-$dart_version-linux-x64.tar.gz"
+wget -q "https://github.com/sass/dart-sass/releases/download/$dart_version/$dart_tarball"
+tar -xvzf "$dart_tarball" -C /tmp/
+sudo install /tmp/dart-sass/sass /usr/bin
diff --git a/scripts/web/dev.sh b/scripts/web/dev.sh
index 96cfc6f7..11f1e207 100755
--- a/scripts/web/dev.sh
+++ b/scripts/web/dev.sh
@@ -3,17 +3,9 @@
# dev.sh builds and starts development environment
set -eux -o pipefail
-# clean up background processes
-function cleanup {
- kill "$devServerPID"
-}
-trap cleanup EXIT
-
dir=$(dirname "${BASH_SOURCE[0]}")
basePath="$dir/../.."
-appPath="$basePath/web"
serverPath="$basePath/pkg/server"
-serverPort=3000
# load env
set -a
@@ -21,20 +13,22 @@ dotenvPath="$serverPath/.env.dev"
source "$dotenvPath"
set +a
-# run webpack-dev-server for js in the background
-(
- BUNDLE_BASE_URL=http://localhost:8080 \
- ASSET_BASE_URL=http://localhost:3000/static \
- ROOT_URL=http://localhost:$serverPort \
- COMPILED_PATH="$appPath"/compiled \
- PUBLIC_PATH="$appPath"/public \
- COMPILED_PATH="$basePath/web/compiled" \
- STANDALONE=true \
- VERSION="$VERSION" \
- WEBPACK_HOST="0.0.0.0" \
- "$dir/webpack-dev.sh"
-) &
-devServerPID=$!
+# copy assets
+mkdir -p "$basePath/pkg/server/static"
+cp "$basePath"/pkg/server/assets/static/* "$basePath/pkg/server/static"
+# run asset pipeline in the background
+(cd "$basePath/pkg/server/assets/" && "$basePath/pkg/server/assets/styles/build.sh" true ) &
+(cd "$basePath/pkg/server/assets/" && "$basePath/pkg/server/assets/js/build.sh" true ) &
# run server
-(cd "$basePath/pkg/watcher" && go run main.go --task="go run main.go start -port 3000" --context="$serverPath" "$serverPath")
+moduleName="github.com/dnote/dnote"
+ldflags="-X '$moduleName/pkg/server/buildinfo.CSSFiles=main.css' -X '$moduleName/pkg/server/buildinfo.JSFiles=main.js' -X '$moduleName/pkg/server/buildinfo.Version=dev' -X '$moduleName/pkg/server/buildinfo.Standalone=true'"
+task="go run -ldflags \"$ldflags\" main.go start -port 3000"
+
+(
+ cd "$basePath/pkg/watcher" && \
+ go run main.go \
+ --task="$task" \
+ --context="$serverPath" \
+ "$serverPath"
+)
diff --git a/test/cli/test-cli b/test/cli/test-cli
new file mode 100755
index 00000000..a4665feb
Binary files /dev/null and b/test/cli/test-cli differ