Simplify by removing web interface (#590)
* Implement MVC * Implement settings * Improve layout * Lock sass dependency
2
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)
|
||||
|
|
|
|||
12
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
|
||||
)
|
||||
|
|
|
|||
49
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=
|
||||
|
|
|
|||
1
pkg/server/.gitignore
vendored
|
|
@ -6,3 +6,4 @@ test-dnote
|
|||
/dist
|
||||
/build
|
||||
server
|
||||
/static
|
||||
|
|
|
|||
|
|
@ -1,187 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020, 2021 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/dnote/dnote/pkg/server/database"
|
||||
"github.com/dnote/dnote/pkg/server/handlers"
|
||||
"github.com/dnote/dnote/pkg/server/helpers"
|
||||
"github.com/dnote/dnote/pkg/server/log"
|
||||
"github.com/dnote/dnote/pkg/server/mailer"
|
||||
"github.com/dnote/dnote/pkg/server/session"
|
||||
"github.com/dnote/dnote/pkg/server/token"
|
||||
"github.com/pkg/errors"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// GetMeResponse is the response for getMe endpoint
|
||||
type GetMeResponse struct {
|
||||
User session.Session `json:"user"`
|
||||
}
|
||||
|
||||
func (a *API) getMe(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := r.Context().Value(helpers.KeyUser).(database.User)
|
||||
if !ok {
|
||||
handlers.DoError(w, "No authenticated user found", nil, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var account database.Account
|
||||
if err := a.App.DB.Where("user_id = ?", user.ID).First(&account).Error; err != nil {
|
||||
handlers.DoError(w, "finding account", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
tx := a.App.DB.Begin()
|
||||
if err := a.App.TouchLastLoginAt(user, tx); err != nil {
|
||||
tx.Rollback()
|
||||
// In case of an error, gracefully continue to avoid disturbing the service
|
||||
log.ErrorWrap(err, "error touching last_login_at")
|
||||
}
|
||||
tx.Commit()
|
||||
|
||||
response := GetMeResponse{
|
||||
User: session.New(user, account),
|
||||
}
|
||||
handlers.RespondJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
type createResetTokenPayload struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
func (a *API) createResetToken(w http.ResponseWriter, r *http.Request) {
|
||||
var params createResetTokenPayload
|
||||
if err := json.NewDecoder(r.Body).Decode(¶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")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,408 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020, 2021 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dnote/dnote/pkg/assert"
|
||||
"github.com/dnote/dnote/pkg/clock"
|
||||
"github.com/dnote/dnote/pkg/server/app"
|
||||
"github.com/dnote/dnote/pkg/server/database"
|
||||
"github.com/dnote/dnote/pkg/server/session"
|
||||
"github.com/dnote/dnote/pkg/server/testutils"
|
||||
"github.com/pkg/errors"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestGetMe(t *testing.T) {
|
||||
testutils.InitTestDB()
|
||||
defer testutils.ClearData(testutils.DB)
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
Clock: clock.NewMock(),
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
u1 := testutils.SetupUserData()
|
||||
a1 := testutils.SetupAccountData(u1, "alice@example.com", "somepassword")
|
||||
|
||||
u2 := testutils.SetupUserData()
|
||||
testutils.MustExec(t, testutils.DB.Model(&u2).Update("cloud", false), "preparing u2 cloud")
|
||||
a2 := testutils.SetupAccountData(u2, "bob@example.com", "somepassword")
|
||||
|
||||
testCases := []struct {
|
||||
user database.User
|
||||
account database.Account
|
||||
expectedPro bool
|
||||
}{
|
||||
{
|
||||
user: u1,
|
||||
account: a1,
|
||||
expectedPro: true,
|
||||
},
|
||||
{
|
||||
user: u2,
|
||||
account: a2,
|
||||
expectedPro: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(fmt.Sprintf("user pro %t", tc.expectedPro), func(t *testing.T) {
|
||||
// Execute
|
||||
req := testutils.MakeReq(server.URL, "GET", "/me", "")
|
||||
res := testutils.HTTPAuthDo(t, req, tc.user)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusOK, "")
|
||||
|
||||
var payload GetMeResponse
|
||||
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
|
||||
t.Fatal(errors.Wrap(err, "decoding payload"))
|
||||
}
|
||||
|
||||
expectedPayload := GetMeResponse{
|
||||
User: session.Session{
|
||||
UUID: tc.user.UUID,
|
||||
Pro: tc.expectedPro,
|
||||
Email: tc.account.Email.String,
|
||||
EmailVerified: tc.account.EmailVerified,
|
||||
},
|
||||
}
|
||||
assert.DeepEqual(t, payload, expectedPayload, "payload mismatch")
|
||||
|
||||
var user database.User
|
||||
testutils.MustExec(t, testutils.DB.Where("id = ?", tc.user.ID).First(&user), "finding user")
|
||||
assert.NotEqual(t, user.LastLoginAt, nil, "LastLoginAt mismatch")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateResetToken(t *testing.T) {
|
||||
t.Run("success", func(t *testing.T) {
|
||||
defer testutils.ClearData(testutils.DB)
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
|
||||
Clock: clock.NewMock(),
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
u := testutils.SetupUserData()
|
||||
testutils.SetupAccountData(u, "alice@example.com", "somepassword")
|
||||
|
||||
dat := `{"email": "alice@example.com"}`
|
||||
req := testutils.MakeReq(server.URL, "POST", "/reset-token", dat)
|
||||
|
||||
// Execute
|
||||
res := testutils.HTTPDo(t, req)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusOK, "Status code mismtach")
|
||||
|
||||
var tokenCount int
|
||||
testutils.MustExec(t, testutils.DB.Model(&database.Token{}).Count(&tokenCount), "counting tokens")
|
||||
|
||||
var resetToken database.Token
|
||||
testutils.MustExec(t, testutils.DB.Where("user_id = ? AND type = ?", u.ID, database.TokenTypeResetPassword).First(&resetToken), "finding reset token")
|
||||
|
||||
assert.Equal(t, tokenCount, 1, "reset_token count mismatch")
|
||||
assert.NotEqual(t, resetToken.Value, nil, "reset_token value mismatch")
|
||||
assert.Equal(t, resetToken.UsedAt, (*time.Time)(nil), "reset_token UsedAt mismatch")
|
||||
})
|
||||
|
||||
t.Run("nonexistent email", func(t *testing.T) {
|
||||
|
||||
defer testutils.ClearData(testutils.DB)
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
|
||||
Clock: clock.NewMock(),
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
u := testutils.SetupUserData()
|
||||
testutils.SetupAccountData(u, "alice@example.com", "somepassword")
|
||||
|
||||
dat := `{"email": "bob@example.com"}`
|
||||
req := testutils.MakeReq(server.URL, "POST", "/reset-token", dat)
|
||||
|
||||
// Execute
|
||||
res := testutils.HTTPDo(t, req)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusOK, "Status code mismtach")
|
||||
|
||||
var tokenCount int
|
||||
testutils.MustExec(t, testutils.DB.Model(&database.Token{}).Count(&tokenCount), "counting tokens")
|
||||
assert.Equal(t, tokenCount, 0, "reset_token count mismatch")
|
||||
})
|
||||
}
|
||||
|
||||
func TestResetPassword(t *testing.T) {
|
||||
t.Run("success", func(t *testing.T) {
|
||||
defer testutils.ClearData(testutils.DB)
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
|
||||
Clock: clock.NewMock(),
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
u := testutils.SetupUserData()
|
||||
a := testutils.SetupAccountData(u, "alice@example.com", "oldpassword")
|
||||
tok := database.Token{
|
||||
UserID: u.ID,
|
||||
Value: "MivFxYiSMMA4An9dP24DNQ==",
|
||||
Type: database.TokenTypeResetPassword,
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&tok), "preparing token")
|
||||
otherTok := database.Token{
|
||||
UserID: u.ID,
|
||||
Value: "somerandomvalue",
|
||||
Type: database.TokenTypeEmailVerification,
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&otherTok), "preparing another token")
|
||||
|
||||
dat := `{"token": "MivFxYiSMMA4An9dP24DNQ==", "password": "newpassword"}`
|
||||
req := testutils.MakeReq(server.URL, "PATCH", "/reset-password", dat)
|
||||
|
||||
s1 := database.Session{
|
||||
Key: "some-session-key-1",
|
||||
UserID: u.ID,
|
||||
ExpiresAt: time.Now().Add(time.Hour * 10 * 24),
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&s1), "preparing user session 1")
|
||||
|
||||
s2 := &database.Session{
|
||||
Key: "some-session-key-2",
|
||||
UserID: u.ID,
|
||||
ExpiresAt: time.Now().Add(time.Hour * 10 * 24),
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&s2), "preparing user session 2")
|
||||
|
||||
anotherUser := testutils.SetupUserData()
|
||||
testutils.MustExec(t, testutils.DB.Save(&database.Session{
|
||||
Key: "some-session-key-3",
|
||||
UserID: anotherUser.ID,
|
||||
ExpiresAt: time.Now().Add(time.Hour * 10 * 24),
|
||||
}), "preparing anotherUser session 1")
|
||||
|
||||
// Execute
|
||||
res := testutils.HTTPDo(t, req)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusOK, "Status code mismatch")
|
||||
|
||||
var resetToken, verificationToken database.Token
|
||||
var account database.Account
|
||||
testutils.MustExec(t, testutils.DB.Where("value = ?", "MivFxYiSMMA4An9dP24DNQ==").First(&resetToken), "finding reset token")
|
||||
testutils.MustExec(t, testutils.DB.Where("value = ?", "somerandomvalue").First(&verificationToken), "finding reset token")
|
||||
testutils.MustExec(t, testutils.DB.Where("id = ?", a.ID).First(&account), "finding account")
|
||||
|
||||
assert.NotEqual(t, resetToken.UsedAt, nil, "reset_token UsedAt mismatch")
|
||||
passwordErr := bcrypt.CompareHashAndPassword([]byte(account.Password.String), []byte("newpassword"))
|
||||
assert.Equal(t, passwordErr, nil, "Password mismatch")
|
||||
assert.Equal(t, verificationToken.UsedAt, (*time.Time)(nil), "verificationToken UsedAt mismatch")
|
||||
|
||||
var s1Count, s2Count int
|
||||
testutils.MustExec(t, testutils.DB.Model(&database.Session{}).Where("id = ?", s1.ID).Count(&s1Count), "counting s1")
|
||||
testutils.MustExec(t, testutils.DB.Model(&database.Session{}).Where("id = ?", s2.ID).Count(&s2Count), "counting s2")
|
||||
|
||||
assert.Equal(t, s1Count, 0, "s1 should have been deleted")
|
||||
assert.Equal(t, s2Count, 0, "s2 should have been deleted")
|
||||
|
||||
var userSessionCount, anotherUserSessionCount int
|
||||
testutils.MustExec(t, testutils.DB.Model(&database.Session{}).Where("user_id = ?", u.ID).Count(&userSessionCount), "counting user session")
|
||||
testutils.MustExec(t, testutils.DB.Model(&database.Session{}).Where("user_id = ?", anotherUser.ID).Count(&anotherUserSessionCount), "counting anotherUser session")
|
||||
|
||||
assert.Equal(t, userSessionCount, 1, "should have created a new user session")
|
||||
assert.Equal(t, anotherUserSessionCount, 1, "anotherUser session count mismatch")
|
||||
})
|
||||
|
||||
t.Run("nonexistent token", func(t *testing.T) {
|
||||
defer testutils.ClearData(testutils.DB)
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
|
||||
Clock: clock.NewMock(),
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
u := testutils.SetupUserData()
|
||||
a := testutils.SetupAccountData(u, "alice@example.com", "somepassword")
|
||||
tok := database.Token{
|
||||
UserID: u.ID,
|
||||
Value: "MivFxYiSMMA4An9dP24DNQ==",
|
||||
Type: database.TokenTypeResetPassword,
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&tok), "preparing token")
|
||||
|
||||
dat := `{"token": "-ApMnyvpg59uOU5b-Kf5uQ==", "password": "oldpassword"}`
|
||||
req := testutils.MakeReq(server.URL, "PATCH", "/reset-password", dat)
|
||||
|
||||
// Execute
|
||||
res := testutils.HTTPDo(t, req)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusBadRequest, "Status code mismatch")
|
||||
|
||||
var resetToken database.Token
|
||||
var account database.Account
|
||||
testutils.MustExec(t, testutils.DB.Where("value = ?", "MivFxYiSMMA4An9dP24DNQ==").First(&resetToken), "finding reset token")
|
||||
testutils.MustExec(t, testutils.DB.Where("id = ?", a.ID).First(&account), "finding account")
|
||||
|
||||
assert.Equal(t, a.Password, account.Password, "password should not have been updated")
|
||||
assert.Equal(t, a.Password, account.Password, "password should not have been updated")
|
||||
assert.Equal(t, resetToken.UsedAt, (*time.Time)(nil), "used_at should be nil")
|
||||
})
|
||||
|
||||
t.Run("expired token", func(t *testing.T) {
|
||||
defer testutils.ClearData(testutils.DB)
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
|
||||
Clock: clock.NewMock(),
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
u := testutils.SetupUserData()
|
||||
a := testutils.SetupAccountData(u, "alice@example.com", "somepassword")
|
||||
tok := database.Token{
|
||||
UserID: u.ID,
|
||||
Value: "MivFxYiSMMA4An9dP24DNQ==",
|
||||
Type: database.TokenTypeResetPassword,
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&tok), "preparing token")
|
||||
testutils.MustExec(t, testutils.DB.Model(&tok).Update("created_at", time.Now().Add(time.Minute*-11)), "Failed to prepare reset_token created_at")
|
||||
|
||||
dat := `{"token": "MivFxYiSMMA4An9dP24DNQ==", "password": "oldpassword"}`
|
||||
req := testutils.MakeReq(server.URL, "PATCH", "/reset-password", dat)
|
||||
|
||||
// Execute
|
||||
res := testutils.HTTPDo(t, req)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusGone, "Status code mismatch")
|
||||
|
||||
var resetToken database.Token
|
||||
var account database.Account
|
||||
testutils.MustExec(t, testutils.DB.Where("value = ?", "MivFxYiSMMA4An9dP24DNQ==").First(&resetToken), "failed to find reset_token")
|
||||
testutils.MustExec(t, testutils.DB.Where("id = ?", a.ID).First(&account), "failed to find account")
|
||||
assert.Equal(t, a.Password, account.Password, "password should not have been updated")
|
||||
assert.Equal(t, resetToken.UsedAt, (*time.Time)(nil), "used_at should be nil")
|
||||
})
|
||||
|
||||
t.Run("used token", func(t *testing.T) {
|
||||
defer testutils.ClearData(testutils.DB)
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
|
||||
Clock: clock.NewMock(),
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
u := testutils.SetupUserData()
|
||||
a := testutils.SetupAccountData(u, "alice@example.com", "somepassword")
|
||||
|
||||
usedAt := time.Now().Add(time.Hour * -11).UTC()
|
||||
tok := database.Token{
|
||||
UserID: u.ID,
|
||||
Value: "MivFxYiSMMA4An9dP24DNQ==",
|
||||
Type: database.TokenTypeResetPassword,
|
||||
UsedAt: &usedAt,
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&tok), "preparing token")
|
||||
testutils.MustExec(t, testutils.DB.Model(&tok).Update("created_at", time.Now().Add(time.Minute*-11)), "Failed to prepare reset_token created_at")
|
||||
|
||||
dat := `{"token": "MivFxYiSMMA4An9dP24DNQ==", "password": "oldpassword"}`
|
||||
req := testutils.MakeReq(server.URL, "PATCH", "/reset-password", dat)
|
||||
|
||||
// Execute
|
||||
res := testutils.HTTPDo(t, req)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusBadRequest, "Status code mismatch")
|
||||
|
||||
var resetToken database.Token
|
||||
var account database.Account
|
||||
testutils.MustExec(t, testutils.DB.Where("value = ?", "MivFxYiSMMA4An9dP24DNQ==").First(&resetToken), "failed to find reset_token")
|
||||
testutils.MustExec(t, testutils.DB.Where("id = ?", a.ID).First(&account), "failed to find account")
|
||||
assert.Equal(t, a.Password, account.Password, "password should not have been updated")
|
||||
|
||||
if resetToken.UsedAt.Year() != usedAt.Year() ||
|
||||
resetToken.UsedAt.Month() != usedAt.Month() ||
|
||||
resetToken.UsedAt.Day() != usedAt.Day() ||
|
||||
resetToken.UsedAt.Hour() != usedAt.Hour() ||
|
||||
resetToken.UsedAt.Minute() != usedAt.Minute() ||
|
||||
resetToken.UsedAt.Second() != usedAt.Second() {
|
||||
t.Errorf("used_at should be %+v but got: %+v", usedAt, resetToken.UsedAt)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("using wrong type token: email_verification", func(t *testing.T) {
|
||||
defer testutils.ClearData(testutils.DB)
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
|
||||
Clock: clock.NewMock(),
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
u := testutils.SetupUserData()
|
||||
a := testutils.SetupAccountData(u, "alice@example.com", "somepassword")
|
||||
tok := database.Token{
|
||||
UserID: u.ID,
|
||||
Value: "MivFxYiSMMA4An9dP24DNQ==",
|
||||
Type: database.TokenTypeEmailVerification,
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&tok), "Failed to prepare reset_token")
|
||||
testutils.MustExec(t, testutils.DB.Model(&tok).Update("created_at", time.Now().Add(time.Minute*-11)), "Failed to prepare reset_token created_at")
|
||||
|
||||
dat := `{"token": "MivFxYiSMMA4An9dP24DNQ==", "password": "oldpassword"}`
|
||||
req := testutils.MakeReq(server.URL, "PATCH", "/reset-password", dat)
|
||||
|
||||
// Execute
|
||||
res := testutils.HTTPDo(t, req)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusBadRequest, "Status code mismatch")
|
||||
|
||||
var resetToken database.Token
|
||||
var account database.Account
|
||||
testutils.MustExec(t, testutils.DB.Where("value = ?", "MivFxYiSMMA4An9dP24DNQ==").First(&resetToken), "failed to find reset_token")
|
||||
testutils.MustExec(t, testutils.DB.Where("id = ?", a.ID).First(&account), "failed to find account")
|
||||
|
||||
assert.Equal(t, a.Password, account.Password, "password should not have been updated")
|
||||
assert.Equal(t, resetToken.UsedAt, (*time.Time)(nil), "used_at should be nil")
|
||||
})
|
||||
}
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020, 2021 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/dnote/dnote/pkg/server/database"
|
||||
"github.com/jinzhu/gorm"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func paginate(conn *gorm.DB, page int) *gorm.DB {
|
||||
limit := 30
|
||||
|
||||
// Paginate
|
||||
if page > 0 {
|
||||
offset := limit * (page - 1)
|
||||
conn = conn.Offset(offset)
|
||||
}
|
||||
|
||||
conn = conn.Limit(limit)
|
||||
|
||||
return conn
|
||||
}
|
||||
|
||||
func getBookIDs(books []database.Book) []int {
|
||||
ret := []int{}
|
||||
|
||||
for _, book := range books {
|
||||
ret = append(ret, book.ID)
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
func validatePassword(password string) error {
|
||||
if len(password) < 8 {
|
||||
return errors.New("Password should be longer than 8 characters")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getClientType(r *http.Request) string {
|
||||
origin := r.Header.Get("Origin")
|
||||
|
||||
if strings.HasPrefix(origin, "moz-extension://") {
|
||||
return "firefox-extension"
|
||||
}
|
||||
|
||||
if strings.HasPrefix(origin, "chrome-extension://") {
|
||||
return "chrome-extension"
|
||||
}
|
||||
|
||||
userAgent := r.Header.Get("User-Agent")
|
||||
if strings.HasPrefix(userAgent, "Go-http-client") {
|
||||
return "cli"
|
||||
}
|
||||
|
||||
return "web"
|
||||
}
|
||||
|
||||
// notSupported is the handler for the route that is no longer supported
|
||||
func (a *API) notSupported(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "API version is not supported. Please upgrade your client.", http.StatusGone)
|
||||
return
|
||||
}
|
||||
|
|
@ -1,324 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020, 2021 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dnote/dnote/pkg/server/database"
|
||||
"github.com/dnote/dnote/pkg/server/handlers"
|
||||
"github.com/dnote/dnote/pkg/server/helpers"
|
||||
"github.com/dnote/dnote/pkg/server/operations"
|
||||
"github.com/dnote/dnote/pkg/server/presenters"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/jinzhu/gorm"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type ftsParams struct {
|
||||
HighlightAll bool
|
||||
}
|
||||
|
||||
func getHeadlineOptions(params *ftsParams) string {
|
||||
headlineOptions := []string{
|
||||
"StartSel=<dnotehl>",
|
||||
"StopSel=</dnotehl>",
|
||||
"ShortWord=0",
|
||||
}
|
||||
|
||||
if params != nil && params.HighlightAll {
|
||||
headlineOptions = append(headlineOptions, "HighlightAll=true")
|
||||
} else {
|
||||
headlineOptions = append(headlineOptions, "MaxFragments=3, MaxWords=50, MinWords=10")
|
||||
}
|
||||
|
||||
return strings.Join(headlineOptions, ",")
|
||||
}
|
||||
|
||||
func selectFTSFields(conn *gorm.DB, search string, params *ftsParams) *gorm.DB {
|
||||
headlineOpts := getHeadlineOptions(params)
|
||||
|
||||
return conn.Select(`
|
||||
notes.id,
|
||||
notes.uuid,
|
||||
notes.created_at,
|
||||
notes.updated_at,
|
||||
notes.book_uuid,
|
||||
notes.user_id,
|
||||
notes.added_on,
|
||||
notes.edited_on,
|
||||
notes.usn,
|
||||
notes.deleted,
|
||||
notes.encrypted,
|
||||
ts_headline('english_nostop', notes.body, plainto_tsquery('english_nostop', ?), ?) AS body
|
||||
`, search, headlineOpts)
|
||||
}
|
||||
|
||||
func respondWithNote(w http.ResponseWriter, note database.Note) {
|
||||
presentedNote := presenters.PresentNote(note)
|
||||
|
||||
handlers.RespondJSON(w, http.StatusOK, presentedNote)
|
||||
}
|
||||
|
||||
func parseSearchQuery(q url.Values) string {
|
||||
searchStr := q.Get("q")
|
||||
|
||||
return escapeSearchQuery(searchStr)
|
||||
}
|
||||
|
||||
func getNoteBaseQuery(db *gorm.DB, noteUUID string, search string) *gorm.DB {
|
||||
var conn *gorm.DB
|
||||
if search != "" {
|
||||
conn = selectFTSFields(db, search, &ftsParams{HighlightAll: true})
|
||||
} else {
|
||||
conn = db
|
||||
}
|
||||
|
||||
conn = conn.Where("notes.uuid = ? AND deleted = ?", noteUUID, false)
|
||||
|
||||
return conn
|
||||
}
|
||||
|
||||
func (a *API) getNote(w http.ResponseWriter, r *http.Request) {
|
||||
user, _, err := handlers.AuthWithSession(a.App.DB, r, nil)
|
||||
if err != nil {
|
||||
handlers.DoError(w, "authenticating", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
noteUUID := vars["noteUUID"]
|
||||
|
||||
note, ok, err := operations.GetNote(a.App.DB, noteUUID, user)
|
||||
if !ok {
|
||||
handlers.RespondNotFound(w)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
handlers.DoError(w, "finding note", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
respondWithNote(w, note)
|
||||
}
|
||||
|
||||
/**** getNotesHandler */
|
||||
|
||||
// GetNotesResponse is a reponse by getNotesHandler
|
||||
type GetNotesResponse struct {
|
||||
Notes []presenters.Note `json:"notes"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
type dateRange struct {
|
||||
lower int64
|
||||
upper int64
|
||||
}
|
||||
|
||||
func (a *API) getNotes(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := r.Context().Value(helpers.KeyUser).(database.User)
|
||||
if !ok {
|
||||
handlers.DoError(w, "No authenticated user found", nil, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
query := r.URL.Query()
|
||||
|
||||
respondGetNotes(a.App.DB, user.ID, query, w)
|
||||
}
|
||||
|
||||
func respondGetNotes(db *gorm.DB, userID int, query url.Values, w http.ResponseWriter) {
|
||||
q, err := parseGetNotesQuery(query)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
conn := getNotesBaseQuery(db, userID, q)
|
||||
|
||||
var total int
|
||||
if err := conn.Model(database.Note{}).Count(&total).Error; err != nil {
|
||||
handlers.DoError(w, "counting total", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
notes := []database.Note{}
|
||||
if total != 0 {
|
||||
conn = orderGetNotes(conn)
|
||||
conn = database.PreloadNote(conn)
|
||||
conn = paginate(conn, q.Page)
|
||||
|
||||
if err := conn.Find(¬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)
|
||||
}
|
||||
|
|
@ -1,357 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020, 2021 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dnote/dnote/pkg/assert"
|
||||
"github.com/dnote/dnote/pkg/clock"
|
||||
"github.com/dnote/dnote/pkg/server/app"
|
||||
"github.com/dnote/dnote/pkg/server/database"
|
||||
"github.com/dnote/dnote/pkg/server/presenters"
|
||||
"github.com/dnote/dnote/pkg/server/testutils"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func getExpectedNotePayload(n database.Note, b database.Book, u database.User) presenters.Note {
|
||||
return presenters.Note{
|
||||
UUID: n.UUID,
|
||||
CreatedAt: n.CreatedAt,
|
||||
UpdatedAt: n.UpdatedAt,
|
||||
Body: n.Body,
|
||||
AddedOn: n.AddedOn,
|
||||
Public: n.Public,
|
||||
USN: n.USN,
|
||||
Book: presenters.NoteBook{
|
||||
UUID: b.UUID,
|
||||
Label: b.Label,
|
||||
},
|
||||
User: presenters.NoteUser{
|
||||
UUID: u.UUID,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetNotes(t *testing.T) {
|
||||
defer testutils.ClearData(testutils.DB)
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
Clock: clock.NewMock(),
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
user := testutils.SetupUserData()
|
||||
anotherUser := testutils.SetupUserData()
|
||||
|
||||
b1 := database.Book{
|
||||
UserID: user.ID,
|
||||
Label: "js",
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&b1), "preparing b1")
|
||||
b2 := database.Book{
|
||||
UserID: user.ID,
|
||||
Label: "css",
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&b2), "preparing b2")
|
||||
b3 := database.Book{
|
||||
UserID: anotherUser.ID,
|
||||
Label: "css",
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&b3), "preparing b3")
|
||||
|
||||
n1 := database.Note{
|
||||
UserID: user.ID,
|
||||
BookUUID: b1.UUID,
|
||||
Body: "n1 content",
|
||||
USN: 11,
|
||||
Deleted: false,
|
||||
AddedOn: time.Date(2018, time.August, 10, 23, 0, 0, 0, time.UTC).UnixNano(),
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&n1), "preparing n1")
|
||||
n2 := database.Note{
|
||||
UserID: user.ID,
|
||||
BookUUID: b1.UUID,
|
||||
Body: "n2 content",
|
||||
USN: 14,
|
||||
Deleted: false,
|
||||
AddedOn: time.Date(2018, time.August, 11, 22, 0, 0, 0, time.UTC).UnixNano(),
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&n2), "preparing n2")
|
||||
n3 := database.Note{
|
||||
UserID: user.ID,
|
||||
BookUUID: b1.UUID,
|
||||
Body: "n3 content",
|
||||
USN: 17,
|
||||
Deleted: false,
|
||||
AddedOn: time.Date(2017, time.January, 10, 23, 0, 0, 0, time.UTC).UnixNano(),
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&n3), "preparing n3")
|
||||
n4 := database.Note{
|
||||
UserID: user.ID,
|
||||
BookUUID: b2.UUID,
|
||||
Body: "n4 content",
|
||||
USN: 18,
|
||||
Deleted: false,
|
||||
AddedOn: time.Date(2018, time.September, 10, 23, 0, 0, 0, time.UTC).UnixNano(),
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&n4), "preparing n4")
|
||||
n5 := database.Note{
|
||||
UserID: anotherUser.ID,
|
||||
BookUUID: b3.UUID,
|
||||
Body: "n5 content",
|
||||
USN: 19,
|
||||
Deleted: false,
|
||||
AddedOn: time.Date(2018, time.August, 10, 23, 0, 0, 0, time.UTC).UnixNano(),
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&n5), "preparing n5")
|
||||
n6 := database.Note{
|
||||
UserID: user.ID,
|
||||
BookUUID: b1.UUID,
|
||||
Body: "",
|
||||
USN: 11,
|
||||
Deleted: true,
|
||||
AddedOn: time.Date(2018, time.August, 10, 23, 0, 0, 0, time.UTC).UnixNano(),
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&n6), "preparing n6")
|
||||
|
||||
// Execute
|
||||
req := testutils.MakeReq(server.URL, "GET", "/notes?year=2018&month=8", "")
|
||||
res := testutils.HTTPAuthDo(t, req, user)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusOK, "")
|
||||
|
||||
var payload GetNotesResponse
|
||||
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
|
||||
t.Fatal(errors.Wrap(err, "decoding payload"))
|
||||
}
|
||||
|
||||
var n2Record, n1Record database.Note
|
||||
testutils.MustExec(t, testutils.DB.Where("uuid = ?", n2.UUID).First(&n2Record), "finding n2Record")
|
||||
testutils.MustExec(t, testutils.DB.Where("uuid = ?", n1.UUID).First(&n1Record), "finding n1Record")
|
||||
|
||||
expected := GetNotesResponse{
|
||||
Notes: []presenters.Note{
|
||||
getExpectedNotePayload(n2Record, b1, user),
|
||||
getExpectedNotePayload(n1Record, b1, user),
|
||||
},
|
||||
Total: 2,
|
||||
}
|
||||
|
||||
assert.DeepEqual(t, payload, expected, "payload mismatch")
|
||||
}
|
||||
|
||||
func TestGetNote(t *testing.T) {
|
||||
defer testutils.ClearData(testutils.DB)
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
Clock: clock.NewMock(),
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
user := testutils.SetupUserData()
|
||||
anotherUser := testutils.SetupUserData()
|
||||
|
||||
b1 := database.Book{
|
||||
UserID: user.ID,
|
||||
Label: "js",
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&b1), "preparing b1")
|
||||
|
||||
privateNote := database.Note{
|
||||
UserID: user.ID,
|
||||
BookUUID: b1.UUID,
|
||||
Body: "privateNote content",
|
||||
Public: false,
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&privateNote), "preparing privateNote")
|
||||
publicNote := database.Note{
|
||||
UserID: user.ID,
|
||||
BookUUID: b1.UUID,
|
||||
Body: "publicNote content",
|
||||
Public: true,
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&publicNote), "preparing publicNote")
|
||||
deletedNote := database.Note{
|
||||
UserID: user.ID,
|
||||
BookUUID: b1.UUID,
|
||||
Deleted: true,
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&deletedNote), "preparing publicNote")
|
||||
|
||||
t.Run("owner accessing private note", func(t *testing.T) {
|
||||
// Execute
|
||||
url := fmt.Sprintf("/notes/%s", privateNote.UUID)
|
||||
req := testutils.MakeReq(server.URL, "GET", url, "")
|
||||
res := testutils.HTTPAuthDo(t, req, user)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusOK, "")
|
||||
|
||||
var payload presenters.Note
|
||||
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
|
||||
t.Fatal(errors.Wrap(err, "decoding payload"))
|
||||
}
|
||||
|
||||
var n1Record database.Note
|
||||
testutils.MustExec(t, testutils.DB.Where("uuid = ?", privateNote.UUID).First(&n1Record), "finding n1Record")
|
||||
|
||||
expected := getExpectedNotePayload(n1Record, b1, user)
|
||||
assert.DeepEqual(t, payload, expected, "payload mismatch")
|
||||
})
|
||||
|
||||
t.Run("owner accessing public note", func(t *testing.T) {
|
||||
// Execute
|
||||
url := fmt.Sprintf("/notes/%s", publicNote.UUID)
|
||||
req := testutils.MakeReq(server.URL, "GET", url, "")
|
||||
res := testutils.HTTPAuthDo(t, req, user)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusOK, "")
|
||||
|
||||
var payload presenters.Note
|
||||
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
|
||||
t.Fatal(errors.Wrap(err, "decoding payload"))
|
||||
}
|
||||
|
||||
var n2Record database.Note
|
||||
testutils.MustExec(t, testutils.DB.Where("uuid = ?", publicNote.UUID).First(&n2Record), "finding n2Record")
|
||||
|
||||
expected := getExpectedNotePayload(n2Record, b1, user)
|
||||
assert.DeepEqual(t, payload, expected, "payload mismatch")
|
||||
})
|
||||
|
||||
t.Run("non-owner accessing public note", func(t *testing.T) {
|
||||
// Execute
|
||||
url := fmt.Sprintf("/notes/%s", publicNote.UUID)
|
||||
req := testutils.MakeReq(server.URL, "GET", url, "")
|
||||
res := testutils.HTTPAuthDo(t, req, anotherUser)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusOK, "")
|
||||
|
||||
var payload presenters.Note
|
||||
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
|
||||
t.Fatal(errors.Wrap(err, "decoding payload"))
|
||||
}
|
||||
|
||||
var n2Record database.Note
|
||||
testutils.MustExec(t, testutils.DB.Where("uuid = ?", publicNote.UUID).First(&n2Record), "finding n2Record")
|
||||
|
||||
expected := getExpectedNotePayload(n2Record, b1, user)
|
||||
assert.DeepEqual(t, payload, expected, "payload mismatch")
|
||||
})
|
||||
|
||||
t.Run("non-owner accessing private note", func(t *testing.T) {
|
||||
// Execute
|
||||
url := fmt.Sprintf("/notes/%s", privateNote.UUID)
|
||||
req := testutils.MakeReq(server.URL, "GET", url, "")
|
||||
res := testutils.HTTPAuthDo(t, req, anotherUser)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusNotFound, "")
|
||||
|
||||
body, err := ioutil.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
t.Fatal(errors.Wrap(err, "reading body"))
|
||||
}
|
||||
|
||||
assert.DeepEqual(t, string(body), "not found\n", "payload mismatch")
|
||||
})
|
||||
|
||||
t.Run("guest accessing public note", func(t *testing.T) {
|
||||
// Execute
|
||||
url := fmt.Sprintf("/notes/%s", publicNote.UUID)
|
||||
req := testutils.MakeReq(server.URL, "GET", url, "")
|
||||
res := testutils.HTTPDo(t, req)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusOK, "")
|
||||
|
||||
var payload presenters.Note
|
||||
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
|
||||
t.Fatal(errors.Wrap(err, "decoding payload"))
|
||||
}
|
||||
|
||||
var n2Record database.Note
|
||||
testutils.MustExec(t, testutils.DB.Where("uuid = ?", publicNote.UUID).First(&n2Record), "finding n2Record")
|
||||
|
||||
expected := getExpectedNotePayload(n2Record, b1, user)
|
||||
assert.DeepEqual(t, payload, expected, "payload mismatch")
|
||||
})
|
||||
|
||||
t.Run("guest accessing private note", func(t *testing.T) {
|
||||
// Execute
|
||||
url := fmt.Sprintf("/notes/%s", privateNote.UUID)
|
||||
req := testutils.MakeReq(server.URL, "GET", url, "")
|
||||
res := testutils.HTTPDo(t, req)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusNotFound, "")
|
||||
|
||||
body, err := ioutil.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
t.Fatal(errors.Wrap(err, "reading body"))
|
||||
}
|
||||
|
||||
assert.DeepEqual(t, string(body), "not found\n", "payload mismatch")
|
||||
})
|
||||
|
||||
t.Run("nonexistent", func(t *testing.T) {
|
||||
// Execute
|
||||
url := fmt.Sprintf("/notes/%s", "someRandomString")
|
||||
req := testutils.MakeReq(server.URL, "GET", url, "")
|
||||
res := testutils.HTTPAuthDo(t, req, user)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusNotFound, "")
|
||||
|
||||
body, err := ioutil.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
t.Fatal(errors.Wrap(err, "reading body"))
|
||||
}
|
||||
|
||||
assert.DeepEqual(t, string(body), "not found\n", "payload mismatch")
|
||||
})
|
||||
|
||||
t.Run("deleted", func(t *testing.T) {
|
||||
// Execute
|
||||
url := fmt.Sprintf("/notes/%s", deletedNote.UUID)
|
||||
req := testutils.MakeReq(server.URL, "GET", url, "")
|
||||
res := testutils.HTTPAuthDo(t, req, user)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusNotFound, "")
|
||||
|
||||
body, err := ioutil.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
t.Fatal(errors.Wrap(err, "reading body"))
|
||||
}
|
||||
|
||||
assert.DeepEqual(t, string(body), "not found\n", "payload mismatch")
|
||||
})
|
||||
}
|
||||
|
|
@ -1,116 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020, 2021 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/dnote/dnote/pkg/server/app"
|
||||
"github.com/dnote/dnote/pkg/server/database"
|
||||
"github.com/dnote/dnote/pkg/server/handlers"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// API is a web API configuration
|
||||
type API struct {
|
||||
App *app.App
|
||||
}
|
||||
|
||||
// init sets up the application based on the configuration
|
||||
func (a *API) init() error {
|
||||
if err := a.App.Validate(); err != nil {
|
||||
return errors.Wrap(err, "validating the app parameters")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyMiddleware(h http.HandlerFunc, rateLimit bool) http.Handler {
|
||||
ret := h
|
||||
ret = handlers.Logging(ret)
|
||||
|
||||
if rateLimit && os.Getenv("GO_ENV") != "TEST" {
|
||||
ret = handlers.Limit(ret)
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
// NewRouter creates and returns a new router
|
||||
func NewRouter(a *API) (*mux.Router, error) {
|
||||
if err := a.init(); err != nil {
|
||||
return nil, errors.Wrap(err, "initializing app")
|
||||
}
|
||||
|
||||
proOnly := handlers.AuthParams{ProOnly: true}
|
||||
app := a.App
|
||||
|
||||
var routes = []handlers.Route{
|
||||
// internal
|
||||
{Method: "GET", Pattern: "/health", HandlerFunc: a.checkHealth, RateLimit: false},
|
||||
{Method: "GET", Pattern: "/me", HandlerFunc: handlers.Auth(app, a.getMe, nil), RateLimit: true},
|
||||
{Method: "POST", Pattern: "/verification-token", HandlerFunc: handlers.Auth(app, a.createVerificationToken, nil), RateLimit: true},
|
||||
{Method: "PATCH", Pattern: "/verify-email", HandlerFunc: a.verifyEmail, RateLimit: true},
|
||||
{Method: "POST", Pattern: "/reset-token", HandlerFunc: a.createResetToken, RateLimit: true},
|
||||
{Method: "PATCH", Pattern: "/reset-password", HandlerFunc: a.resetPassword, RateLimit: true},
|
||||
{Method: "PATCH", Pattern: "/account/profile", HandlerFunc: handlers.Auth(app, a.updateProfile, nil), RateLimit: true},
|
||||
{Method: "PATCH", Pattern: "/account/password", HandlerFunc: handlers.Auth(app, a.updatePassword, nil), RateLimit: true},
|
||||
{Method: "GET", Pattern: "/account/email-preference", HandlerFunc: handlers.TokenAuth(app, a.getEmailPreference, database.TokenTypeEmailPreference, nil), RateLimit: true},
|
||||
{Method: "PATCH", Pattern: "/account/email-preference", HandlerFunc: handlers.TokenAuth(app, a.updateEmailPreference, database.TokenTypeEmailPreference, nil), RateLimit: true},
|
||||
{Method: "GET", Pattern: "/notes", HandlerFunc: handlers.Auth(app, a.getNotes, nil), RateLimit: false},
|
||||
{Method: "GET", Pattern: "/notes/{noteUUID}", HandlerFunc: a.getNote, RateLimit: true},
|
||||
{Method: "GET", Pattern: "/calendar", HandlerFunc: handlers.Auth(app, a.getCalendar, nil), RateLimit: true},
|
||||
|
||||
// v3
|
||||
{Method: "GET", Pattern: "/v3/sync/fragment", HandlerFunc: handlers.Cors(handlers.Auth(app, a.GetSyncFragment, &proOnly)), RateLimit: false},
|
||||
{Method: "GET", Pattern: "/v3/sync/state", HandlerFunc: handlers.Cors(handlers.Auth(app, a.GetSyncState, &proOnly)), RateLimit: false},
|
||||
{Method: "OPTIONS", Pattern: "/v3/books", HandlerFunc: handlers.Cors(a.BooksOptions), RateLimit: true},
|
||||
{Method: "GET", Pattern: "/v3/books", HandlerFunc: handlers.Cors(handlers.Auth(app, a.GetBooks, &proOnly)), RateLimit: true},
|
||||
{Method: "GET", Pattern: "/v3/books/{bookUUID}", HandlerFunc: handlers.Cors(handlers.Auth(app, a.GetBook, &proOnly)), RateLimit: true},
|
||||
{Method: "POST", Pattern: "/v3/books", HandlerFunc: handlers.Cors(handlers.Auth(app, a.CreateBook, &proOnly)), RateLimit: false},
|
||||
{Method: "PATCH", Pattern: "/v3/books/{bookUUID}", HandlerFunc: handlers.Cors(handlers.Auth(app, a.UpdateBook, &proOnly)), RateLimit: false},
|
||||
{Method: "DELETE", Pattern: "/v3/books/{bookUUID}", HandlerFunc: handlers.Cors(handlers.Auth(app, a.DeleteBook, &proOnly)), RateLimit: false},
|
||||
{Method: "OPTIONS", Pattern: "/v3/notes", HandlerFunc: handlers.Cors(a.NotesOptions), RateLimit: true},
|
||||
{Method: "POST", Pattern: "/v3/notes", HandlerFunc: handlers.Cors(handlers.Auth(app, a.CreateNote, &proOnly)), RateLimit: false},
|
||||
{Method: "PATCH", Pattern: "/v3/notes/{noteUUID}", HandlerFunc: handlers.Auth(app, a.UpdateNote, &proOnly), RateLimit: false},
|
||||
{Method: "DELETE", Pattern: "/v3/notes/{noteUUID}", HandlerFunc: handlers.Auth(app, a.DeleteNote, &proOnly), RateLimit: false},
|
||||
{Method: "POST", Pattern: "/v3/signin", HandlerFunc: handlers.Cors(a.signin), RateLimit: true},
|
||||
{Method: "OPTIONS", Pattern: "/v3/signout", HandlerFunc: handlers.Cors(a.signoutOptions), RateLimit: true},
|
||||
{Method: "POST", Pattern: "/v3/signout", HandlerFunc: handlers.Cors(a.signout), RateLimit: true},
|
||||
{Method: "POST", Pattern: "/v3/register", HandlerFunc: a.register, RateLimit: true},
|
||||
}
|
||||
|
||||
router := mux.NewRouter().StrictSlash(true)
|
||||
|
||||
router.PathPrefix("/v1").Handler(applyMiddleware(handlers.NotSupported, true))
|
||||
router.PathPrefix("/v2").Handler(applyMiddleware(handlers.NotSupported, true))
|
||||
|
||||
for _, route := range routes {
|
||||
handler := route.HandlerFunc
|
||||
|
||||
router.
|
||||
Methods(route.Method).
|
||||
Path(route.Pattern).
|
||||
Handler(applyMiddleware(handler, route.RateLimit))
|
||||
}
|
||||
|
||||
return router, nil
|
||||
}
|
||||
|
|
@ -1,161 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020, 2021 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/dnote/dnote/pkg/assert"
|
||||
"github.com/dnote/dnote/pkg/clock"
|
||||
"github.com/dnote/dnote/pkg/server/app"
|
||||
"github.com/dnote/dnote/pkg/server/config"
|
||||
"github.com/dnote/dnote/pkg/server/mailer"
|
||||
"github.com/dnote/dnote/pkg/server/testutils"
|
||||
"github.com/jinzhu/gorm"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func TestNotSupportedVersions(t *testing.T) {
|
||||
testCases := []struct {
|
||||
path string
|
||||
}{
|
||||
// v1
|
||||
{
|
||||
path: "/v1",
|
||||
},
|
||||
{
|
||||
path: "/v1/foo",
|
||||
},
|
||||
{
|
||||
path: "/v1/bar/baz",
|
||||
},
|
||||
// v2
|
||||
{
|
||||
path: "/v2",
|
||||
},
|
||||
{
|
||||
path: "/v2/foo",
|
||||
},
|
||||
{
|
||||
path: "/v2/bar/baz",
|
||||
},
|
||||
}
|
||||
|
||||
// setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
DB: &gorm.DB{},
|
||||
Clock: clock.NewMock(),
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.path, func(t *testing.T) {
|
||||
// execute
|
||||
req := testutils.MakeReq(server.URL, "GET", tc.path, "")
|
||||
res := testutils.HTTPDo(t, req)
|
||||
|
||||
// test
|
||||
assert.Equal(t, res.StatusCode, http.StatusGone, "status code mismatch")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRouter_AppValidate(t *testing.T) {
|
||||
c := config.Load()
|
||||
|
||||
configWithoutWebURL := config.Load()
|
||||
configWithoutWebURL.WebURL = ""
|
||||
|
||||
testCases := []struct {
|
||||
app app.App
|
||||
expectedErr error
|
||||
}{
|
||||
{
|
||||
app: app.App{
|
||||
DB: &gorm.DB{},
|
||||
Clock: clock.NewMock(),
|
||||
EmailTemplates: mailer.Templates{},
|
||||
EmailBackend: &testutils.MockEmailbackendImplementation{},
|
||||
Config: c,
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
app: app.App{
|
||||
DB: nil,
|
||||
Clock: clock.NewMock(),
|
||||
EmailTemplates: mailer.Templates{},
|
||||
EmailBackend: &testutils.MockEmailbackendImplementation{},
|
||||
Config: c,
|
||||
},
|
||||
expectedErr: app.ErrEmptyDB,
|
||||
},
|
||||
{
|
||||
app: app.App{
|
||||
DB: &gorm.DB{},
|
||||
Clock: nil,
|
||||
EmailTemplates: mailer.Templates{},
|
||||
EmailBackend: &testutils.MockEmailbackendImplementation{},
|
||||
Config: c,
|
||||
},
|
||||
expectedErr: app.ErrEmptyClock,
|
||||
},
|
||||
{
|
||||
app: app.App{
|
||||
DB: &gorm.DB{},
|
||||
Clock: clock.NewMock(),
|
||||
EmailTemplates: nil,
|
||||
EmailBackend: &testutils.MockEmailbackendImplementation{},
|
||||
Config: c,
|
||||
},
|
||||
expectedErr: app.ErrEmptyEmailTemplates,
|
||||
},
|
||||
{
|
||||
app: app.App{
|
||||
DB: &gorm.DB{},
|
||||
Clock: clock.NewMock(),
|
||||
EmailTemplates: mailer.Templates{},
|
||||
EmailBackend: nil,
|
||||
Config: c,
|
||||
},
|
||||
expectedErr: app.ErrEmptyEmailBackend,
|
||||
},
|
||||
{
|
||||
app: app.App{
|
||||
DB: &gorm.DB{},
|
||||
Clock: clock.NewMock(),
|
||||
EmailTemplates: mailer.Templates{},
|
||||
EmailBackend: &testutils.MockEmailbackendImplementation{},
|
||||
Config: configWithoutWebURL,
|
||||
},
|
||||
expectedErr: app.ErrEmptyWebURL,
|
||||
},
|
||||
}
|
||||
|
||||
for idx, tc := range testCases {
|
||||
t.Run(fmt.Sprintf("test case %d", idx), func(t *testing.T) {
|
||||
api := API{App: &tc.app}
|
||||
_, err := NewRouter(&api)
|
||||
|
||||
assert.Equal(t, errors.Cause(err), tc.expectedErr, "error mismatch")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,394 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020, 2021 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/dnote/dnote/pkg/server/database"
|
||||
"github.com/dnote/dnote/pkg/server/handlers"
|
||||
"github.com/dnote/dnote/pkg/server/helpers"
|
||||
"github.com/dnote/dnote/pkg/server/log"
|
||||
"github.com/dnote/dnote/pkg/server/mailer"
|
||||
"github.com/dnote/dnote/pkg/server/presenters"
|
||||
"github.com/dnote/dnote/pkg/server/session"
|
||||
"github.com/dnote/dnote/pkg/server/token"
|
||||
"github.com/jinzhu/gorm"
|
||||
"github.com/pkg/errors"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type updateProfilePayload struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// updateProfile updates user
|
||||
func (a *API) updateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := r.Context().Value(helpers.KeyUser).(database.User)
|
||||
if !ok {
|
||||
handlers.DoError(w, "No authenticated user found", nil, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var account database.Account
|
||||
if err := a.App.DB.Where("user_id = ?", user.ID).First(&account).Error; err != nil {
|
||||
handlers.DoError(w, "getting account", nil, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var params updateProfilePayload
|
||||
err := json.NewDecoder(r.Body).Decode(¶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)
|
||||
}
|
||||
|
|
@ -1,691 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020, 2021 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dnote/dnote/pkg/assert"
|
||||
"github.com/dnote/dnote/pkg/clock"
|
||||
"github.com/dnote/dnote/pkg/server/app"
|
||||
"github.com/dnote/dnote/pkg/server/database"
|
||||
"github.com/dnote/dnote/pkg/server/presenters"
|
||||
"github.com/dnote/dnote/pkg/server/testutils"
|
||||
"github.com/pkg/errors"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestUpdatePassword(t *testing.T) {
|
||||
t.Run("success", func(t *testing.T) {
|
||||
defer testutils.ClearData(testutils.DB)
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
Clock: clock.NewMock(),
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
user := testutils.SetupUserData()
|
||||
testutils.SetupAccountData(user, "alice@example.com", "oldpassword")
|
||||
|
||||
// Execute
|
||||
dat := `{"old_password": "oldpassword", "new_password": "newpassword"}`
|
||||
req := testutils.MakeReq(server.URL, "PATCH", "/account/password", dat)
|
||||
res := testutils.HTTPAuthDo(t, req, user)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusOK, "Status code mismsatch")
|
||||
|
||||
var account database.Account
|
||||
testutils.MustExec(t, testutils.DB.Where("user_id = ?", user.ID).First(&account), "finding account")
|
||||
|
||||
passwordErr := bcrypt.CompareHashAndPassword([]byte(account.Password.String), []byte("newpassword"))
|
||||
assert.Equal(t, passwordErr, nil, "Password mismatch")
|
||||
})
|
||||
|
||||
t.Run("old password mismatch", func(t *testing.T) {
|
||||
defer testutils.ClearData(testutils.DB)
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
|
||||
Clock: clock.NewMock(),
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
u := testutils.SetupUserData()
|
||||
a := testutils.SetupAccountData(u, "alice@example.com", "oldpassword")
|
||||
|
||||
// Execute
|
||||
dat := `{"old_password": "randompassword", "new_password": "newpassword"}`
|
||||
req := testutils.MakeReq(server.URL, "PATCH", "/account/password", dat)
|
||||
res := testutils.HTTPAuthDo(t, req, u)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusUnauthorized, "Status code mismsatch")
|
||||
|
||||
var account database.Account
|
||||
testutils.MustExec(t, testutils.DB.Where("user_id = ?", u.ID).First(&account), "finding account")
|
||||
assert.Equal(t, a.Password.String, account.Password.String, "password should not have been updated")
|
||||
})
|
||||
|
||||
t.Run("password too short", func(t *testing.T) {
|
||||
defer testutils.ClearData(testutils.DB)
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
|
||||
Clock: clock.NewMock(),
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
u := testutils.SetupUserData()
|
||||
a := testutils.SetupAccountData(u, "alice@example.com", "oldpassword")
|
||||
|
||||
// Execute
|
||||
dat := `{"old_password": "oldpassword", "new_password": "a"}`
|
||||
req := testutils.MakeReq(server.URL, "PATCH", "/account/password", dat)
|
||||
res := testutils.HTTPAuthDo(t, req, u)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusBadRequest, "Status code mismsatch")
|
||||
|
||||
var account database.Account
|
||||
testutils.MustExec(t, testutils.DB.Where("user_id = ?", u.ID).First(&account), "finding account")
|
||||
assert.Equal(t, a.Password.String, account.Password.String, "password should not have been updated")
|
||||
})
|
||||
}
|
||||
|
||||
func TestCreateVerificationToken(t *testing.T) {
|
||||
t.Run("success", func(t *testing.T) {
|
||||
defer testutils.ClearData(testutils.DB)
|
||||
|
||||
// Setup
|
||||
emailBackend := testutils.MockEmailbackendImplementation{}
|
||||
server := MustNewServer(t, &app.App{
|
||||
Clock: clock.NewMock(),
|
||||
EmailBackend: &emailBackend,
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
user := testutils.SetupUserData()
|
||||
testutils.SetupAccountData(user, "alice@example.com", "pass1234")
|
||||
|
||||
// Execute
|
||||
req := testutils.MakeReq(server.URL, "POST", "/verification-token", "")
|
||||
res := testutils.HTTPAuthDo(t, req, user)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusCreated, "status code mismatch")
|
||||
|
||||
var account database.Account
|
||||
var token database.Token
|
||||
var tokenCount int
|
||||
testutils.MustExec(t, testutils.DB.Where("user_id = ?", user.ID).First(&account), "finding account")
|
||||
testutils.MustExec(t, testutils.DB.Where("user_id = ? AND type = ?", user.ID, database.TokenTypeEmailVerification).First(&token), "finding token")
|
||||
testutils.MustExec(t, testutils.DB.Model(&database.Token{}).Count(&tokenCount), "counting token")
|
||||
|
||||
assert.Equal(t, account.EmailVerified, false, "email_verified should not have been updated")
|
||||
assert.NotEqual(t, token.Value, "", "token Value mismatch")
|
||||
assert.Equal(t, tokenCount, 1, "token count mismatch")
|
||||
assert.Equal(t, token.UsedAt, (*time.Time)(nil), "token UsedAt mismatch")
|
||||
assert.Equal(t, len(emailBackend.Emails), 1, "email queue count mismatch")
|
||||
})
|
||||
|
||||
t.Run("already verified", func(t *testing.T) {
|
||||
|
||||
defer testutils.ClearData(testutils.DB)
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
|
||||
Clock: clock.NewMock(),
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
user := testutils.SetupUserData()
|
||||
a := testutils.SetupAccountData(user, "alice@example.com", "pass1234")
|
||||
a.EmailVerified = true
|
||||
testutils.MustExec(t, testutils.DB.Save(&a), "preparing account")
|
||||
|
||||
// Execute
|
||||
req := testutils.MakeReq(server.URL, "POST", "/verification-token", "")
|
||||
res := testutils.HTTPAuthDo(t, req, user)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusGone, "Status code mismatch")
|
||||
|
||||
var account database.Account
|
||||
var tokenCount int
|
||||
testutils.MustExec(t, testutils.DB.Where("user_id = ?", user.ID).First(&account), "finding account")
|
||||
testutils.MustExec(t, testutils.DB.Model(&database.Token{}).Count(&tokenCount), "counting token")
|
||||
|
||||
assert.Equal(t, account.EmailVerified, true, "email_verified should not have been updated")
|
||||
assert.Equal(t, tokenCount, 0, "token count mismatch")
|
||||
})
|
||||
}
|
||||
|
||||
func TestVerifyEmail(t *testing.T) {
|
||||
t.Run("success", func(t *testing.T) {
|
||||
|
||||
defer testutils.ClearData(testutils.DB)
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
Clock: clock.NewMock(),
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
user := testutils.SetupUserData()
|
||||
testutils.SetupAccountData(user, "alice@example.com", "pass1234")
|
||||
tok := database.Token{
|
||||
UserID: user.ID,
|
||||
Type: database.TokenTypeEmailVerification,
|
||||
Value: "someTokenValue",
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&tok), "preparing token")
|
||||
|
||||
dat := `{"token": "someTokenValue"}`
|
||||
req := testutils.MakeReq(server.URL, "PATCH", "/verify-email", dat)
|
||||
|
||||
// Execute
|
||||
res := testutils.HTTPAuthDo(t, req, user)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusOK, "Status code mismatch")
|
||||
|
||||
var account database.Account
|
||||
var token database.Token
|
||||
var tokenCount int
|
||||
testutils.MustExec(t, testutils.DB.Where("user_id = ?", user.ID).First(&account), "finding account")
|
||||
testutils.MustExec(t, testutils.DB.Where("user_id = ? AND type = ?", user.ID, database.TokenTypeEmailVerification).First(&token), "finding token")
|
||||
testutils.MustExec(t, testutils.DB.Model(&database.Token{}).Count(&tokenCount), "counting token")
|
||||
|
||||
assert.Equal(t, account.EmailVerified, true, "email_verified mismatch")
|
||||
assert.NotEqual(t, token.Value, "", "token value should not have been updated")
|
||||
assert.Equal(t, tokenCount, 1, "token count mismatch")
|
||||
assert.NotEqual(t, token.UsedAt, (*time.Time)(nil), "token should have been used")
|
||||
})
|
||||
|
||||
t.Run("used token", func(t *testing.T) {
|
||||
|
||||
defer testutils.ClearData(testutils.DB)
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
Clock: clock.NewMock(),
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
user := testutils.SetupUserData()
|
||||
testutils.SetupAccountData(user, "alice@example.com", "pass1234")
|
||||
|
||||
usedAt := time.Now().Add(time.Hour * -11).UTC()
|
||||
tok := database.Token{
|
||||
UserID: user.ID,
|
||||
Type: database.TokenTypeEmailVerification,
|
||||
Value: "someTokenValue",
|
||||
UsedAt: &usedAt,
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&tok), "preparing token")
|
||||
|
||||
dat := `{"token": "someTokenValue"}`
|
||||
req := testutils.MakeReq(server.URL, "PATCH", "/verify-email", dat)
|
||||
|
||||
// Execute
|
||||
res := testutils.HTTPAuthDo(t, req, user)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusBadRequest, "")
|
||||
|
||||
var account database.Account
|
||||
var token database.Token
|
||||
var tokenCount int
|
||||
testutils.MustExec(t, testutils.DB.Where("user_id = ?", user.ID).First(&account), "finding account")
|
||||
testutils.MustExec(t, testutils.DB.Where("user_id = ? AND type = ?", user.ID, database.TokenTypeEmailVerification).First(&token), "finding token")
|
||||
testutils.MustExec(t, testutils.DB.Model(&database.Token{}).Count(&tokenCount), "counting token")
|
||||
|
||||
assert.Equal(t, account.EmailVerified, false, "email_verified mismatch")
|
||||
assert.NotEqual(t, token.UsedAt, nil, "token used_at mismatch")
|
||||
assert.Equal(t, tokenCount, 1, "token count mismatch")
|
||||
assert.NotEqual(t, token.UsedAt, (*time.Time)(nil), "token should have been used")
|
||||
})
|
||||
|
||||
t.Run("expired token", func(t *testing.T) {
|
||||
|
||||
defer testutils.ClearData(testutils.DB)
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
Clock: clock.NewMock(),
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
user := testutils.SetupUserData()
|
||||
testutils.SetupAccountData(user, "alice@example.com", "pass1234")
|
||||
|
||||
tok := database.Token{
|
||||
UserID: user.ID,
|
||||
Type: database.TokenTypeEmailVerification,
|
||||
Value: "someTokenValue",
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&tok), "preparing token")
|
||||
testutils.MustExec(t, testutils.DB.Model(&tok).Update("created_at", time.Now().Add(time.Minute*-31)), "Failed to prepare token created_at")
|
||||
|
||||
dat := `{"token": "someTokenValue"}`
|
||||
req := testutils.MakeReq(server.URL, "PATCH", "/verify-email", dat)
|
||||
|
||||
// Execute
|
||||
res := testutils.HTTPAuthDo(t, req, user)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusGone, "")
|
||||
|
||||
var account database.Account
|
||||
var token database.Token
|
||||
var tokenCount int
|
||||
testutils.MustExec(t, testutils.DB.Where("user_id = ?", user.ID).First(&account), "finding account")
|
||||
testutils.MustExec(t, testutils.DB.Where("user_id = ? AND type = ?", user.ID, database.TokenTypeEmailVerification).First(&token), "finding token")
|
||||
testutils.MustExec(t, testutils.DB.Model(&database.Token{}).Count(&tokenCount), "counting token")
|
||||
|
||||
assert.Equal(t, account.EmailVerified, false, "email_verified mismatch")
|
||||
assert.Equal(t, tokenCount, 1, "token count mismatch")
|
||||
assert.Equal(t, token.UsedAt, (*time.Time)(nil), "token should have not been used")
|
||||
})
|
||||
|
||||
t.Run("already verified", func(t *testing.T) {
|
||||
|
||||
defer testutils.ClearData(testutils.DB)
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
Clock: clock.NewMock(),
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
user := testutils.SetupUserData()
|
||||
a := testutils.SetupAccountData(user, "alice@example.com", "oldpass1234")
|
||||
a.EmailVerified = true
|
||||
testutils.MustExec(t, testutils.DB.Save(&a), "preparing account")
|
||||
|
||||
tok := database.Token{
|
||||
UserID: user.ID,
|
||||
Type: database.TokenTypeEmailVerification,
|
||||
Value: "someTokenValue",
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&tok), "preparing token")
|
||||
|
||||
dat := `{"token": "someTokenValue"}`
|
||||
req := testutils.MakeReq(server.URL, "PATCH", "/verify-email", dat)
|
||||
|
||||
// Execute
|
||||
res := testutils.HTTPAuthDo(t, req, user)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusConflict, "")
|
||||
|
||||
var account database.Account
|
||||
var token database.Token
|
||||
var tokenCount int
|
||||
testutils.MustExec(t, testutils.DB.Where("user_id = ?", user.ID).First(&account), "finding account")
|
||||
testutils.MustExec(t, testutils.DB.Where("user_id = ? AND type = ?", user.ID, database.TokenTypeEmailVerification).First(&token), "finding token")
|
||||
testutils.MustExec(t, testutils.DB.Model(&database.Token{}).Count(&tokenCount), "counting token")
|
||||
|
||||
assert.Equal(t, account.EmailVerified, true, "email_verified mismatch")
|
||||
assert.Equal(t, tokenCount, 1, "token count mismatch")
|
||||
assert.Equal(t, token.UsedAt, (*time.Time)(nil), "token should have not been used")
|
||||
})
|
||||
}
|
||||
|
||||
func TestUpdateEmail(t *testing.T) {
|
||||
t.Run("success", func(t *testing.T) {
|
||||
defer testutils.ClearData(testutils.DB)
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
Clock: clock.NewMock(),
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
u := testutils.SetupUserData()
|
||||
a := testutils.SetupAccountData(u, "alice@example.com", "pass1234")
|
||||
a.EmailVerified = true
|
||||
testutils.MustExec(t, testutils.DB.Save(&a), "updating email_verified")
|
||||
|
||||
// Execute
|
||||
dat := `{"email": "alice-new@example.com", "password": "pass1234"}`
|
||||
req := testutils.MakeReq(server.URL, "PATCH", "/account/profile", dat)
|
||||
res := testutils.HTTPAuthDo(t, req, u)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusOK, "")
|
||||
|
||||
var user database.User
|
||||
var account database.Account
|
||||
testutils.MustExec(t, testutils.DB.Where("id = ?", u.ID).First(&user), "finding user")
|
||||
testutils.MustExec(t, testutils.DB.Where("user_id = ?", u.ID).First(&account), "finding account")
|
||||
|
||||
assert.Equal(t, account.Email.String, "alice-new@example.com", "email mismatch")
|
||||
assert.Equal(t, account.EmailVerified, false, "EmailVerified mismatch")
|
||||
})
|
||||
|
||||
t.Run("password mismatch", func(t *testing.T) {
|
||||
defer testutils.ClearData(testutils.DB)
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
Clock: clock.NewMock(),
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
u := testutils.SetupUserData()
|
||||
a := testutils.SetupAccountData(u, "alice@example.com", "pass1234")
|
||||
a.EmailVerified = true
|
||||
testutils.MustExec(t, testutils.DB.Save(&a), "updating email_verified")
|
||||
|
||||
// Execute
|
||||
dat := `{"email": "alice-new@example.com", "password": "wrongpassword"}`
|
||||
req := testutils.MakeReq(server.URL, "PATCH", "/account/profile", dat)
|
||||
res := testutils.HTTPAuthDo(t, req, u)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusUnauthorized, "Status code mismsatch")
|
||||
|
||||
var user database.User
|
||||
var account database.Account
|
||||
testutils.MustExec(t, testutils.DB.Where("id = ?", u.ID).First(&user), "finding user")
|
||||
testutils.MustExec(t, testutils.DB.Where("user_id = ?", u.ID).First(&account), "finding account")
|
||||
|
||||
assert.Equal(t, account.Email.String, "alice@example.com", "email mismatch")
|
||||
assert.Equal(t, account.EmailVerified, true, "EmailVerified mismatch")
|
||||
})
|
||||
}
|
||||
|
||||
func TestUpdateEmailPreference(t *testing.T) {
|
||||
t.Run("with login", func(t *testing.T) {
|
||||
defer testutils.ClearData(testutils.DB)
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
Clock: clock.NewMock(),
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
u := testutils.SetupUserData()
|
||||
testutils.SetupEmailPreferenceData(u, false)
|
||||
|
||||
// Execute
|
||||
dat := `{"inactive_reminder": true}`
|
||||
req := testutils.MakeReq(server.URL, "PATCH", "/account/email-preference", dat)
|
||||
res := testutils.HTTPAuthDo(t, req, u)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusOK, "")
|
||||
|
||||
var preference database.EmailPreference
|
||||
testutils.MustExec(t, testutils.DB.Where("user_id = ?", u.ID).First(&preference), "finding account")
|
||||
assert.Equal(t, preference.InactiveReminder, true, "preference mismatch")
|
||||
})
|
||||
|
||||
t.Run("with an unused token", func(t *testing.T) {
|
||||
defer testutils.ClearData(testutils.DB)
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
Clock: clock.NewMock(),
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
u := testutils.SetupUserData()
|
||||
testutils.SetupEmailPreferenceData(u, false)
|
||||
tok := database.Token{
|
||||
UserID: u.ID,
|
||||
Type: database.TokenTypeEmailPreference,
|
||||
Value: "someTokenValue",
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&tok), "preparing token")
|
||||
|
||||
// Execute
|
||||
dat := `{"inactive_reminder": true}`
|
||||
url := fmt.Sprintf("/account/email-preference?token=%s", "someTokenValue")
|
||||
req := testutils.MakeReq(server.URL, "PATCH", url, dat)
|
||||
res := testutils.HTTPDo(t, req)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusOK, "")
|
||||
|
||||
var preference database.EmailPreference
|
||||
var preferenceCount int
|
||||
var token database.Token
|
||||
testutils.MustExec(t, testutils.DB.Where("user_id = ?", u.ID).First(&preference), "finding preference")
|
||||
testutils.MustExec(t, testutils.DB.Model(database.EmailPreference{}).Count(&preferenceCount), "counting preference")
|
||||
testutils.MustExec(t, testutils.DB.Where("id = ?", tok.ID).First(&token), "failed to find token")
|
||||
|
||||
assert.Equal(t, preferenceCount, 1, "preference count mismatch")
|
||||
assert.Equal(t, preference.InactiveReminder, true, "email mismatch")
|
||||
assert.NotEqual(t, token.UsedAt, (*time.Time)(nil), "token should have been used")
|
||||
})
|
||||
|
||||
t.Run("with nonexistent token", func(t *testing.T) {
|
||||
defer testutils.ClearData(testutils.DB)
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
Clock: clock.NewMock(),
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
u := testutils.SetupUserData()
|
||||
testutils.SetupEmailPreferenceData(u, true)
|
||||
tok := database.Token{
|
||||
UserID: u.ID,
|
||||
Type: database.TokenTypeEmailPreference,
|
||||
Value: "someTokenValue",
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&tok), "preparing token")
|
||||
|
||||
dat := `{"inactive_reminder": false}`
|
||||
url := fmt.Sprintf("/account/email-preference?token=%s", "someNonexistentToken")
|
||||
req := testutils.MakeReq(server.URL, "PATCH", url, dat)
|
||||
|
||||
// Execute
|
||||
res := testutils.HTTPDo(t, req)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusUnauthorized, "")
|
||||
|
||||
var preference database.EmailPreference
|
||||
testutils.MustExec(t, testutils.DB.Where("user_id = ?", u.ID).First(&preference), "finding preference")
|
||||
assert.Equal(t, preference.InactiveReminder, true, "email mismatch")
|
||||
})
|
||||
|
||||
t.Run("with expired token", func(t *testing.T) {
|
||||
defer testutils.ClearData(testutils.DB)
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
|
||||
Clock: clock.NewMock(),
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
u := testutils.SetupUserData()
|
||||
testutils.SetupEmailPreferenceData(u, true)
|
||||
|
||||
usedAt := time.Now().Add(-11 * time.Minute)
|
||||
tok := database.Token{
|
||||
UserID: u.ID,
|
||||
Type: database.TokenTypeEmailPreference,
|
||||
Value: "someTokenValue",
|
||||
UsedAt: &usedAt,
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&tok), "preparing token")
|
||||
|
||||
// Execute
|
||||
dat := `{"inactive_reminder": false}`
|
||||
url := fmt.Sprintf("/account/email-preference?token=%s", "someTokenValue")
|
||||
req := testutils.MakeReq(server.URL, "PATCH", url, dat)
|
||||
res := testutils.HTTPDo(t, req)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusUnauthorized, "")
|
||||
|
||||
var preference database.EmailPreference
|
||||
testutils.MustExec(t, testutils.DB.Where("user_id = ?", u.ID).First(&preference), "finding preference")
|
||||
assert.Equal(t, preference.InactiveReminder, true, "email mismatch")
|
||||
})
|
||||
|
||||
t.Run("with a used but unexpired token", func(t *testing.T) {
|
||||
|
||||
defer testutils.ClearData(testutils.DB)
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
|
||||
Clock: clock.NewMock(),
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
u := testutils.SetupUserData()
|
||||
testutils.SetupEmailPreferenceData(u, true)
|
||||
usedAt := time.Now().Add(-9 * time.Minute)
|
||||
tok := database.Token{
|
||||
UserID: u.ID,
|
||||
Type: database.TokenTypeEmailPreference,
|
||||
Value: "someTokenValue",
|
||||
UsedAt: &usedAt,
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&tok), "preparing token")
|
||||
|
||||
dat := `{"inactive_reminder": false}`
|
||||
url := fmt.Sprintf("/account/email-preference?token=%s", "someTokenValue")
|
||||
req := testutils.MakeReq(server.URL, "PATCH", url, dat)
|
||||
|
||||
// Execute
|
||||
res := testutils.HTTPDo(t, req)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusOK, "")
|
||||
|
||||
var preference database.EmailPreference
|
||||
testutils.MustExec(t, testutils.DB.Where("user_id = ?", u.ID).First(&preference), "finding preference")
|
||||
assert.Equal(t, preference.InactiveReminder, false, "InactiveReminder mismatch")
|
||||
})
|
||||
|
||||
t.Run("no user and no token", func(t *testing.T) {
|
||||
|
||||
defer testutils.ClearData(testutils.DB)
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
|
||||
Clock: clock.NewMock(),
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
u := testutils.SetupUserData()
|
||||
testutils.SetupEmailPreferenceData(u, true)
|
||||
|
||||
// Execute
|
||||
dat := `{"inactive_reminder": false}`
|
||||
req := testutils.MakeReq(server.URL, "PATCH", "/account/email-preference", dat)
|
||||
res := testutils.HTTPDo(t, req)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusUnauthorized, "")
|
||||
|
||||
var preference database.EmailPreference
|
||||
testutils.MustExec(t, testutils.DB.Where("user_id = ?", u.ID).First(&preference), "finding preference")
|
||||
assert.Equal(t, preference.InactiveReminder, true, "email mismatch")
|
||||
})
|
||||
|
||||
t.Run("create a record if not exists", func(t *testing.T) {
|
||||
|
||||
defer testutils.ClearData(testutils.DB)
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
|
||||
Clock: clock.NewMock(),
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
u := testutils.SetupUserData()
|
||||
tok := database.Token{
|
||||
UserID: u.ID,
|
||||
Type: database.TokenTypeEmailPreference,
|
||||
Value: "someTokenValue",
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&tok), "preparing token")
|
||||
|
||||
// Execute
|
||||
dat := `{"inactive_reminder": false}`
|
||||
url := fmt.Sprintf("/account/email-preference?token=%s", "someTokenValue")
|
||||
req := testutils.MakeReq(server.URL, "PATCH", url, dat)
|
||||
res := testutils.HTTPDo(t, req)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusOK, "")
|
||||
|
||||
var preferenceCount int
|
||||
testutils.MustExec(t, testutils.DB.Model(database.EmailPreference{}).Count(&preferenceCount), "counting preference")
|
||||
assert.Equal(t, preferenceCount, 1, "preference count mismatch")
|
||||
|
||||
var preference database.EmailPreference
|
||||
testutils.MustExec(t, testutils.DB.Where("user_id = ?", u.ID).First(&preference), "finding preference")
|
||||
assert.Equal(t, preference.InactiveReminder, false, "email mismatch")
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetEmailPreference(t *testing.T) {
|
||||
defer testutils.ClearData(testutils.DB)
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
|
||||
Clock: clock.NewMock(),
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
u := testutils.SetupUserData()
|
||||
pref := testutils.SetupEmailPreferenceData(u, true)
|
||||
|
||||
// Execute
|
||||
req := testutils.MakeReq(server.URL, "GET", "/account/email-preference", "")
|
||||
res := testutils.HTTPAuthDo(t, req, u)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusOK, "")
|
||||
|
||||
var got presenters.EmailPreference
|
||||
if err := json.NewDecoder(res.Body).Decode(&got); err != nil {
|
||||
t.Fatal(errors.Wrap(err, "decoding payload"))
|
||||
}
|
||||
|
||||
expected := presenters.EmailPreference{
|
||||
InactiveReminder: pref.InactiveReminder,
|
||||
ProductUpdate: pref.ProductUpdate,
|
||||
CreatedAt: presenters.FormatTS(pref.CreatedAt),
|
||||
UpdatedAt: presenters.FormatTS(pref.UpdatedAt),
|
||||
}
|
||||
assert.DeepEqual(t, got, expected, "payload mismatch")
|
||||
}
|
||||
|
|
@ -1,226 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020, 2021 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/dnote/dnote/pkg/server/database"
|
||||
"github.com/dnote/dnote/pkg/server/handlers"
|
||||
"github.com/dnote/dnote/pkg/server/log"
|
||||
"github.com/jinzhu/gorm"
|
||||
"github.com/pkg/errors"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// ErrLoginFailure is an error for failed login
|
||||
var ErrLoginFailure = errors.New("Wrong email and password combination")
|
||||
|
||||
// SessionResponse is a response containing a session information
|
||||
type SessionResponse struct {
|
||||
Key string `json:"key"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
}
|
||||
|
||||
func setSessionCookie(w http.ResponseWriter, key string, expires time.Time) {
|
||||
cookie := http.Cookie{
|
||||
Name: "id",
|
||||
Value: key,
|
||||
Expires: expires,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
}
|
||||
http.SetCookie(w, &cookie)
|
||||
}
|
||||
|
||||
func touchLastLoginAt(db *gorm.DB, user database.User) error {
|
||||
t := time.Now()
|
||||
if err := db.Model(&user).Update(database.User{LastLoginAt: &t}).Error; err != nil {
|
||||
return errors.Wrap(err, "updating last_login_at")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type signinPayload struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func (a *API) signin(w http.ResponseWriter, r *http.Request) {
|
||||
var params signinPayload
|
||||
err := json.NewDecoder(r.Body).Decode(¶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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,482 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020, 2021 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dnote/dnote/pkg/assert"
|
||||
"github.com/dnote/dnote/pkg/clock"
|
||||
"github.com/dnote/dnote/pkg/server/app"
|
||||
"github.com/dnote/dnote/pkg/server/config"
|
||||
"github.com/dnote/dnote/pkg/server/database"
|
||||
"github.com/dnote/dnote/pkg/server/testutils"
|
||||
"github.com/pkg/errors"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func assertSessionResp(t *testing.T, res *http.Response) {
|
||||
// after register, should sign in user
|
||||
var got SessionResponse
|
||||
if err := json.NewDecoder(res.Body).Decode(&got); err != nil {
|
||||
t.Fatal(errors.Wrap(err, "decoding payload"))
|
||||
}
|
||||
|
||||
var sessionCount int
|
||||
var session database.Session
|
||||
testutils.MustExec(t, testutils.DB.Model(&database.Session{}).Count(&sessionCount), "counting session")
|
||||
testutils.MustExec(t, testutils.DB.First(&session), "getting session")
|
||||
|
||||
assert.Equal(t, sessionCount, 1, "sessionCount mismatch")
|
||||
assert.Equal(t, got.Key, session.Key, "session Key mismatch")
|
||||
assert.Equal(t, got.ExpiresAt, session.ExpiresAt.Unix(), "session ExpiresAt mismatch")
|
||||
|
||||
c := testutils.GetCookieByName(res.Cookies(), "id")
|
||||
assert.Equal(t, c.Value, session.Key, "session key mismatch")
|
||||
assert.Equal(t, c.Path, "/", "session path mismatch")
|
||||
assert.Equal(t, c.HttpOnly, true, "session HTTPOnly mismatch")
|
||||
assert.Equal(t, c.Expires.Unix(), session.ExpiresAt.Unix(), "session Expires mismatch")
|
||||
}
|
||||
|
||||
func TestRegister(t *testing.T) {
|
||||
testCases := []struct {
|
||||
email string
|
||||
password string
|
||||
onPremise bool
|
||||
expectedPro bool
|
||||
}{
|
||||
{
|
||||
email: "alice@example.com",
|
||||
password: "pass1234",
|
||||
onPremise: false,
|
||||
expectedPro: false,
|
||||
},
|
||||
{
|
||||
email: "bob@example.com",
|
||||
password: "Y9EwmjH@Jq6y5a64MSACUoM4w7SAhzvY",
|
||||
onPremise: false,
|
||||
expectedPro: false,
|
||||
},
|
||||
{
|
||||
email: "chuck@example.com",
|
||||
password: "e*H@kJi^vXbWEcD9T5^Am!Y@7#Po2@PC",
|
||||
onPremise: false,
|
||||
expectedPro: false,
|
||||
},
|
||||
// on premise
|
||||
{
|
||||
email: "dan@example.com",
|
||||
password: "e*H@kJi^vXbWEcD9T5^Am!Y@7#Po2@PC",
|
||||
onPremise: true,
|
||||
expectedPro: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(fmt.Sprintf("register %s %s", tc.email, tc.password), func(t *testing.T) {
|
||||
defer testutils.ClearData(testutils.DB)
|
||||
|
||||
c := config.Load()
|
||||
c.SetOnPremise(tc.onPremise)
|
||||
|
||||
// Setup
|
||||
emailBackend := testutils.MockEmailbackendImplementation{}
|
||||
server := MustNewServer(t, &app.App{
|
||||
Clock: clock.NewMock(),
|
||||
EmailBackend: &emailBackend,
|
||||
Config: c,
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
dat := fmt.Sprintf(`{"email": "%s", "password": "%s"}`, tc.email, tc.password)
|
||||
req := testutils.MakeReq(server.URL, "POST", "/v3/register", dat)
|
||||
|
||||
// Execute
|
||||
res := testutils.HTTPDo(t, req)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusCreated, "")
|
||||
|
||||
var account database.Account
|
||||
testutils.MustExec(t, testutils.DB.Where("email = ?", tc.email).First(&account), "finding account")
|
||||
assert.Equal(t, account.Email.String, tc.email, "Email mismatch")
|
||||
assert.NotEqual(t, account.UserID, 0, "UserID mismatch")
|
||||
passwordErr := bcrypt.CompareHashAndPassword([]byte(account.Password.String), []byte(tc.password))
|
||||
assert.Equal(t, passwordErr, nil, "Password mismatch")
|
||||
|
||||
var user database.User
|
||||
testutils.MustExec(t, testutils.DB.Where("id = ?", account.UserID).First(&user), "finding user")
|
||||
assert.Equal(t, user.Cloud, tc.expectedPro, "Cloud mismatch")
|
||||
assert.Equal(t, user.MaxUSN, 0, "MaxUSN mismatch")
|
||||
|
||||
// welcome email
|
||||
assert.Equalf(t, len(emailBackend.Emails), 1, "email queue count mismatch")
|
||||
assert.DeepEqual(t, emailBackend.Emails[0].To, []string{tc.email}, "email to mismatch")
|
||||
|
||||
// after register, should sign in user
|
||||
assertSessionResp(t, res)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterMissingParams(t *testing.T) {
|
||||
t.Run("missing email", func(t *testing.T) {
|
||||
defer testutils.ClearData(testutils.DB)
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
Clock: clock.NewMock(),
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
dat := fmt.Sprintf(`{"password": %s}`, "SLMZFM5RmSjA5vfXnG5lPOnrpZSbtmV76cnAcrlr2yU")
|
||||
req := testutils.MakeReq(server.URL, "POST", "/v3/register", dat)
|
||||
|
||||
// Execute
|
||||
res := testutils.HTTPDo(t, req)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusBadRequest, "Status mismatch")
|
||||
|
||||
var accountCount, userCount int
|
||||
testutils.MustExec(t, testutils.DB.Model(&database.Account{}).Count(&accountCount), "counting account")
|
||||
testutils.MustExec(t, testutils.DB.Model(&database.User{}).Count(&userCount), "counting user")
|
||||
|
||||
assert.Equal(t, accountCount, 0, "accountCount mismatch")
|
||||
assert.Equal(t, userCount, 0, "userCount mismatch")
|
||||
})
|
||||
|
||||
t.Run("missing password", func(t *testing.T) {
|
||||
|
||||
defer testutils.ClearData(testutils.DB)
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
Clock: clock.NewMock(),
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
dat := fmt.Sprintf(`{"email": "%s"}`, "alice@example.com")
|
||||
req := testutils.MakeReq(server.URL, "POST", "/v3/register", dat)
|
||||
|
||||
// Execute
|
||||
res := testutils.HTTPDo(t, req)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusBadRequest, "Status mismatch")
|
||||
|
||||
var accountCount, userCount int
|
||||
testutils.MustExec(t, testutils.DB.Model(&database.Account{}).Count(&accountCount), "counting account")
|
||||
testutils.MustExec(t, testutils.DB.Model(&database.User{}).Count(&userCount), "counting user")
|
||||
|
||||
assert.Equal(t, accountCount, 0, "accountCount mismatch")
|
||||
assert.Equal(t, userCount, 0, "userCount mismatch")
|
||||
})
|
||||
}
|
||||
|
||||
func TestRegisterDuplicateEmail(t *testing.T) {
|
||||
defer testutils.ClearData(testutils.DB)
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
Clock: clock.NewMock(),
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
u := testutils.SetupUserData()
|
||||
testutils.SetupAccountData(u, "alice@example.com", "somepassword")
|
||||
|
||||
dat := `{"email": "alice@example.com", "password": "foobarbaz"}`
|
||||
req := testutils.MakeReq(server.URL, "POST", "/v3/register", dat)
|
||||
|
||||
// Execute
|
||||
res := testutils.HTTPDo(t, req)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusBadRequest, "status code mismatch")
|
||||
|
||||
var accountCount, userCount, verificationTokenCount int
|
||||
testutils.MustExec(t, testutils.DB.Model(&database.Account{}).Count(&accountCount), "counting account")
|
||||
testutils.MustExec(t, testutils.DB.Model(&database.User{}).Count(&userCount), "counting user")
|
||||
testutils.MustExec(t, testutils.DB.Model(&database.Token{}).Count(&verificationTokenCount), "counting verification token")
|
||||
|
||||
var user database.User
|
||||
testutils.MustExec(t, testutils.DB.Where("id = ?", u.ID).First(&user), "finding user")
|
||||
|
||||
assert.Equal(t, accountCount, 1, "account count mismatch")
|
||||
assert.Equal(t, userCount, 1, "user count mismatch")
|
||||
assert.Equal(t, verificationTokenCount, 0, "verification_token should not have been created")
|
||||
assert.Equal(t, user.LastLoginAt, (*time.Time)(nil), "LastLoginAt mismatch")
|
||||
}
|
||||
|
||||
func TestRegisterDisabled(t *testing.T) {
|
||||
defer testutils.ClearData(testutils.DB)
|
||||
|
||||
c := config.Load()
|
||||
c.DisableRegistration = true
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
Clock: clock.NewMock(),
|
||||
Config: c,
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
dat := `{"email": "alice@example.com", "password": "foobarbaz"}`
|
||||
req := testutils.MakeReq(server.URL, "POST", "/v3/register", dat)
|
||||
|
||||
// Execute
|
||||
res := testutils.HTTPDo(t, req)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusForbidden, "status code mismatch")
|
||||
|
||||
var accountCount, userCount int
|
||||
testutils.MustExec(t, testutils.DB.Model(&database.Account{}).Count(&accountCount), "counting account")
|
||||
testutils.MustExec(t, testutils.DB.Model(&database.User{}).Count(&userCount), "counting user")
|
||||
|
||||
assert.Equal(t, accountCount, 0, "account count mismatch")
|
||||
assert.Equal(t, userCount, 0, "user count mismatch")
|
||||
}
|
||||
|
||||
func TestSignIn(t *testing.T) {
|
||||
t.Run("success", func(t *testing.T) {
|
||||
defer testutils.ClearData(testutils.DB)
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
|
||||
Clock: clock.NewMock(),
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
u := testutils.SetupUserData()
|
||||
testutils.SetupAccountData(u, "alice@example.com", "pass1234")
|
||||
|
||||
dat := `{"email": "alice@example.com", "password": "pass1234"}`
|
||||
req := testutils.MakeReq(server.URL, "POST", "/v3/signin", dat)
|
||||
|
||||
// Execute
|
||||
res := testutils.HTTPDo(t, req)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusOK, "")
|
||||
|
||||
var user database.User
|
||||
testutils.MustExec(t, testutils.DB.Model(&database.User{}).First(&user), "finding user")
|
||||
assert.NotEqual(t, user.LastLoginAt, nil, "LastLoginAt mismatch")
|
||||
|
||||
// after register, should sign in user
|
||||
assertSessionResp(t, res)
|
||||
})
|
||||
|
||||
t.Run("wrong password", func(t *testing.T) {
|
||||
defer testutils.ClearData(testutils.DB)
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
|
||||
Clock: clock.NewMock(),
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
u := testutils.SetupUserData()
|
||||
testutils.SetupAccountData(u, "alice@example.com", "pass1234")
|
||||
|
||||
dat := `{"email": "alice@example.com", "password": "wrongpassword1234"}`
|
||||
req := testutils.MakeReq(server.URL, "POST", "/v3/signin", dat)
|
||||
|
||||
// Execute
|
||||
res := testutils.HTTPDo(t, req)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusUnauthorized, "")
|
||||
|
||||
var user database.User
|
||||
testutils.MustExec(t, testutils.DB.Model(&database.User{}).First(&user), "finding user")
|
||||
assert.Equal(t, user.LastLoginAt, (*time.Time)(nil), "LastLoginAt mismatch")
|
||||
|
||||
var sessionCount int
|
||||
testutils.MustExec(t, testutils.DB.Model(&database.Session{}).Count(&sessionCount), "counting session")
|
||||
assert.Equal(t, sessionCount, 0, "sessionCount mismatch")
|
||||
})
|
||||
|
||||
t.Run("wrong email", func(t *testing.T) {
|
||||
|
||||
defer testutils.ClearData(testutils.DB)
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
|
||||
Clock: clock.NewMock(),
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
u := testutils.SetupUserData()
|
||||
testutils.SetupAccountData(u, "alice@example.com", "pass1234")
|
||||
|
||||
dat := `{"email": "bob@example.com", "password": "pass1234"}`
|
||||
req := testutils.MakeReq(server.URL, "POST", "/v3/signin", dat)
|
||||
|
||||
// Execute
|
||||
res := testutils.HTTPDo(t, req)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusUnauthorized, "")
|
||||
|
||||
var user database.User
|
||||
testutils.MustExec(t, testutils.DB.Model(&database.User{}).First(&user), "finding user")
|
||||
assert.DeepEqual(t, user.LastLoginAt, (*time.Time)(nil), "LastLoginAt mismatch")
|
||||
|
||||
var sessionCount int
|
||||
testutils.MustExec(t, testutils.DB.Model(&database.Session{}).Count(&sessionCount), "counting session")
|
||||
assert.Equal(t, sessionCount, 0, "sessionCount mismatch")
|
||||
})
|
||||
|
||||
t.Run("nonexistent email", func(t *testing.T) {
|
||||
|
||||
defer testutils.ClearData(testutils.DB)
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
|
||||
Clock: clock.NewMock(),
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
dat := `{"email": "nonexistent@example.com", "password": "pass1234"}`
|
||||
req := testutils.MakeReq(server.URL, "POST", "/v3/signin", dat)
|
||||
|
||||
// Execute
|
||||
res := testutils.HTTPDo(t, req)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusUnauthorized, "")
|
||||
|
||||
var sessionCount int
|
||||
testutils.MustExec(t, testutils.DB.Model(&database.Session{}).Count(&sessionCount), "counting session")
|
||||
assert.Equal(t, sessionCount, 0, "sessionCount mismatch")
|
||||
})
|
||||
}
|
||||
|
||||
func TestSignout(t *testing.T) {
|
||||
t.Run("authenticated", func(t *testing.T) {
|
||||
|
||||
defer testutils.ClearData(testutils.DB)
|
||||
|
||||
aliceUser := testutils.SetupUserData()
|
||||
testutils.SetupAccountData(aliceUser, "alice@example.com", "pass1234")
|
||||
anotherUser := testutils.SetupUserData()
|
||||
|
||||
session1 := database.Session{
|
||||
Key: "A9xgggqzTHETy++GDi1NpDNe0iyqosPm9bitdeNGkJU=",
|
||||
UserID: aliceUser.ID,
|
||||
ExpiresAt: time.Now().Add(time.Hour * 24),
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&session1), "preparing session1")
|
||||
session2 := database.Session{
|
||||
Key: "MDCpbvCRg7W2sH6S870wqLqZDZTObYeVd0PzOekfo/A=",
|
||||
UserID: anotherUser.ID,
|
||||
ExpiresAt: time.Now().Add(time.Hour * 24),
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&session2), "preparing session2")
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
|
||||
Clock: clock.NewMock(),
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
// Execute
|
||||
req := testutils.MakeReq(server.URL, "POST", "/v3/signout", "")
|
||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", "A9xgggqzTHETy++GDi1NpDNe0iyqosPm9bitdeNGkJU="))
|
||||
res := testutils.HTTPDo(t, req)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusNoContent, "Status mismatch")
|
||||
|
||||
var sessionCount int
|
||||
var s2 database.Session
|
||||
testutils.MustExec(t, testutils.DB.Model(&database.Session{}).Count(&sessionCount), "counting session")
|
||||
testutils.MustExec(t, testutils.DB.Where("key = ?", "MDCpbvCRg7W2sH6S870wqLqZDZTObYeVd0PzOekfo/A=").First(&s2), "getting s2")
|
||||
|
||||
assert.Equal(t, sessionCount, 1, "sessionCount mismatch")
|
||||
|
||||
c := testutils.GetCookieByName(res.Cookies(), "id")
|
||||
assert.Equal(t, c.Value, "", "session key mismatch")
|
||||
assert.Equal(t, c.Path, "/", "session path mismatch")
|
||||
assert.Equal(t, c.HttpOnly, true, "session HTTPOnly mismatch")
|
||||
if c.Expires.After(time.Now()) {
|
||||
t.Error("session cookie is not expired")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unauthenticated", func(t *testing.T) {
|
||||
|
||||
defer testutils.ClearData(testutils.DB)
|
||||
|
||||
aliceUser := testutils.SetupUserData()
|
||||
testutils.SetupAccountData(aliceUser, "alice@example.com", "pass1234")
|
||||
anotherUser := testutils.SetupUserData()
|
||||
|
||||
session1 := database.Session{
|
||||
Key: "A9xgggqzTHETy++GDi1NpDNe0iyqosPm9bitdeNGkJU=",
|
||||
UserID: aliceUser.ID,
|
||||
ExpiresAt: time.Now().Add(time.Hour * 24),
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&session1), "preparing session1")
|
||||
session2 := database.Session{
|
||||
Key: "MDCpbvCRg7W2sH6S870wqLqZDZTObYeVd0PzOekfo/A=",
|
||||
UserID: anotherUser.ID,
|
||||
ExpiresAt: time.Now().Add(time.Hour * 24),
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&session2), "preparing session2")
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
|
||||
Clock: clock.NewMock(),
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
// Execute
|
||||
req := testutils.MakeReq(server.URL, "POST", "/v3/signout", "")
|
||||
res := testutils.HTTPDo(t, req)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusNoContent, "Status mismatch")
|
||||
|
||||
var sessionCount int
|
||||
var postSession1, postSession2 database.Session
|
||||
testutils.MustExec(t, testutils.DB.Model(&database.Session{}).Count(&sessionCount), "counting session")
|
||||
testutils.MustExec(t, testutils.DB.Where("key = ?", "A9xgggqzTHETy++GDi1NpDNe0iyqosPm9bitdeNGkJU=").First(&postSession1), "getting postSession1")
|
||||
testutils.MustExec(t, testutils.DB.Where("key = ?", "MDCpbvCRg7W2sH6S870wqLqZDZTObYeVd0PzOekfo/A=").First(&postSession2), "getting postSession2")
|
||||
|
||||
// two existing sessions should remain
|
||||
assert.Equal(t, sessionCount, 2, "sessionCount mismatch")
|
||||
|
||||
c := testutils.GetCookieByName(res.Cookies(), "id")
|
||||
assert.Equal(t, c, (*http.Cookie)(nil), "id cookie should have not been set")
|
||||
})
|
||||
}
|
||||
|
|
@ -1,267 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020, 2021 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/dnote/dnote/pkg/server/database"
|
||||
"github.com/dnote/dnote/pkg/server/handlers"
|
||||
"github.com/dnote/dnote/pkg/server/helpers"
|
||||
"github.com/dnote/dnote/pkg/server/presenters"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/jinzhu/gorm"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type createBookPayload struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// CreateBookResp is the response from create book api
|
||||
type CreateBookResp struct {
|
||||
Book presenters.Book `json:"book"`
|
||||
}
|
||||
|
||||
func validateCreateBookPayload(p createBookPayload) error {
|
||||
if p.Name == "" {
|
||||
return errors.New("name is required")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateBook creates a new book
|
||||
func (a *API) CreateBook(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := r.Context().Value(helpers.KeyUser).(database.User)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var params createBookPayload
|
||||
err := json.NewDecoder(r.Body).Decode(¶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)
|
||||
}
|
||||
|
|
@ -1,220 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020, 2021 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/dnote/dnote/pkg/server/app"
|
||||
"github.com/dnote/dnote/pkg/server/database"
|
||||
"github.com/dnote/dnote/pkg/server/handlers"
|
||||
"github.com/dnote/dnote/pkg/server/helpers"
|
||||
"github.com/dnote/dnote/pkg/server/presenters"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type updateNotePayload struct {
|
||||
BookUUID *string `json:"book_uuid"`
|
||||
Content *string `json:"content"`
|
||||
Public *bool `json:"public"`
|
||||
}
|
||||
|
||||
type updateNoteResp struct {
|
||||
Status int `json:"status"`
|
||||
Result presenters.Note `json:"result"`
|
||||
}
|
||||
|
||||
func validateUpdateNotePayload(p updateNotePayload) bool {
|
||||
return p.BookUUID != nil || p.Content != nil || p.Public != nil
|
||||
}
|
||||
|
||||
// UpdateNote updates note
|
||||
func (a *API) UpdateNote(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
noteUUID := vars["noteUUID"]
|
||||
|
||||
user, ok := r.Context().Value(helpers.KeyUser).(database.User)
|
||||
if !ok {
|
||||
handlers.DoError(w, "No authenticated user found", nil, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var params updateNotePayload
|
||||
err := json.NewDecoder(r.Body).Decode(¶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")
|
||||
}
|
||||
|
|
@ -1,394 +0,0 @@
|
|||
/* Copyright (C) 2019, 2020, 2021 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/dnote/dnote/pkg/assert"
|
||||
"github.com/dnote/dnote/pkg/clock"
|
||||
"github.com/dnote/dnote/pkg/server/app"
|
||||
"github.com/dnote/dnote/pkg/server/database"
|
||||
"github.com/dnote/dnote/pkg/server/testutils"
|
||||
)
|
||||
|
||||
func TestCreateNote(t *testing.T) {
|
||||
|
||||
defer testutils.ClearData(testutils.DB)
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
|
||||
Clock: clock.NewMock(),
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
user := testutils.SetupUserData()
|
||||
testutils.MustExec(t, testutils.DB.Model(&user).Update("max_usn", 101), "preparing user max_usn")
|
||||
|
||||
b1 := database.Book{
|
||||
UserID: user.ID,
|
||||
Label: "js",
|
||||
USN: 58,
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&b1), "preparing b1")
|
||||
|
||||
// Execute
|
||||
dat := fmt.Sprintf(`{"book_uuid": "%s", "content": "note content"}`, b1.UUID)
|
||||
req := testutils.MakeReq(server.URL, "POST", "/v3/notes", dat)
|
||||
res := testutils.HTTPAuthDo(t, req, user)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusCreated, "")
|
||||
|
||||
var noteRecord database.Note
|
||||
var bookRecord database.Book
|
||||
var userRecord database.User
|
||||
var bookCount, noteCount int
|
||||
testutils.MustExec(t, testutils.DB.Model(&database.Book{}).Count(&bookCount), "counting books")
|
||||
testutils.MustExec(t, testutils.DB.Model(&database.Note{}).Count(¬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")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -116,6 +116,10 @@ func (a *App) SendWelcomeEmail(email string) error {
|
|||
|
||||
// SendPasswordResetEmail sends password reset email
|
||||
func (a *App) SendPasswordResetEmail(email, tokenValue string) error {
|
||||
if email == "" {
|
||||
return ErrEmailRequired
|
||||
}
|
||||
|
||||
body, err := a.EmailTemplates.Execute(mailer.EmailTypeResetPassword, mailer.EmailKindText, mailer.EmailResetPasswordTmplData{
|
||||
AccountEmail: email,
|
||||
Token: tokenValue,
|
||||
|
|
@ -131,6 +135,10 @@ func (a *App) SendPasswordResetEmail(email, tokenValue string) error {
|
|||
}
|
||||
|
||||
if err := a.EmailBackend.Queue("Reset your password", from, []string{email}, mailer.EmailKindText, body); err != nil {
|
||||
if errors.Cause(err) == mailer.ErrSMTPNotConfigured {
|
||||
return ErrInvalidSMTPConfig
|
||||
}
|
||||
|
||||
return errors.Wrapf(err, "queueing email for %s", email)
|
||||
}
|
||||
|
||||
|
|
|
|||
67
pkg/server/app/errors.go
Normal file
|
|
@ -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."
|
||||
)
|
||||
|
|
@ -19,6 +19,9 @@
|
|||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dnote/dnote/pkg/server/database"
|
||||
"github.com/dnote/dnote/pkg/server/helpers"
|
||||
"github.com/jinzhu/gorm"
|
||||
|
|
@ -174,3 +177,144 @@ func (a *App) GetUserNoteByUUID(userID int, uuid string) (*database.Note, error)
|
|||
|
||||
return &ret, nil
|
||||
}
|
||||
|
||||
// GetNotesParams is params for finding notes
|
||||
type GetNotesParams struct {
|
||||
Year int
|
||||
Month int
|
||||
Page int
|
||||
Books []string
|
||||
Search string
|
||||
Encrypted bool
|
||||
PerPage int
|
||||
}
|
||||
|
||||
type ftsParams struct {
|
||||
HighlightAll bool
|
||||
}
|
||||
|
||||
func getHeadlineOptions(params *ftsParams) string {
|
||||
headlineOptions := []string{
|
||||
"StartSel=<dnotehl>",
|
||||
"StopSel=</dnotehl>",
|
||||
"ShortWord=0",
|
||||
}
|
||||
|
||||
if params != nil && params.HighlightAll {
|
||||
headlineOptions = append(headlineOptions, "HighlightAll=true")
|
||||
} else {
|
||||
headlineOptions = append(headlineOptions, "MaxFragments=3, MaxWords=50, MinWords=10")
|
||||
}
|
||||
|
||||
return strings.Join(headlineOptions, ",")
|
||||
}
|
||||
|
||||
func selectFTSFields(conn *gorm.DB, search string, params *ftsParams) *gorm.DB {
|
||||
headlineOpts := getHeadlineOptions(params)
|
||||
|
||||
return conn.Select(`
|
||||
notes.id,
|
||||
notes.uuid,
|
||||
notes.created_at,
|
||||
notes.updated_at,
|
||||
notes.book_uuid,
|
||||
notes.user_id,
|
||||
notes.added_on,
|
||||
notes.edited_on,
|
||||
notes.usn,
|
||||
notes.deleted,
|
||||
notes.encrypted,
|
||||
ts_headline('english_nostop', notes.body, plainto_tsquery('english_nostop', ?), ?) AS body
|
||||
`, search, headlineOpts)
|
||||
}
|
||||
|
||||
func getNotesBaseQuery(db *gorm.DB, userID int, q GetNotesParams) *gorm.DB {
|
||||
conn := db.Where(
|
||||
"notes.user_id = ? AND notes.deleted = ? AND notes.encrypted = ?",
|
||||
userID, false, q.Encrypted,
|
||||
)
|
||||
|
||||
if q.Search != "" {
|
||||
conn = selectFTSFields(conn, q.Search, nil)
|
||||
conn = conn.Where("tsv @@ plainto_tsquery('english_nostop', ?)", q.Search)
|
||||
}
|
||||
|
||||
if len(q.Books) > 0 {
|
||||
conn = conn.Joins("INNER JOIN books ON books.uuid = notes.book_uuid").
|
||||
Where("books.label in (?)", q.Books)
|
||||
}
|
||||
|
||||
if q.Year != 0 || q.Month != 0 {
|
||||
dateLowerbound, dateUpperbound := getDateBounds(q.Year, q.Month)
|
||||
conn = conn.Where("notes.added_on >= ? AND notes.added_on < ?", dateLowerbound, dateUpperbound)
|
||||
}
|
||||
|
||||
return conn
|
||||
}
|
||||
|
||||
func getDateBounds(year, month int) (int64, int64) {
|
||||
var yearUpperbound, monthUpperbound int
|
||||
|
||||
if month == 12 {
|
||||
monthUpperbound = 1
|
||||
yearUpperbound = year + 1
|
||||
} else {
|
||||
monthUpperbound = month + 1
|
||||
yearUpperbound = year
|
||||
}
|
||||
|
||||
lower := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC).UnixNano()
|
||||
upper := time.Date(yearUpperbound, time.Month(monthUpperbound), 1, 0, 0, 0, 0, time.UTC).UnixNano()
|
||||
|
||||
return lower, upper
|
||||
}
|
||||
|
||||
func orderGetNotes(conn *gorm.DB) *gorm.DB {
|
||||
return conn.Order("notes.updated_at DESC, notes.id DESC")
|
||||
}
|
||||
|
||||
func paginate(conn *gorm.DB, page, perPage int) *gorm.DB {
|
||||
// Paginate
|
||||
if page > 0 {
|
||||
offset := perPage * (page - 1)
|
||||
conn = conn.Offset(offset)
|
||||
}
|
||||
|
||||
conn = conn.Limit(perPage)
|
||||
|
||||
return conn
|
||||
}
|
||||
|
||||
// GetNotesResult is the result of getting notes
|
||||
type GetNotesResult struct {
|
||||
Notes []database.Note
|
||||
Total int
|
||||
}
|
||||
|
||||
// GetNotes returns a list of matching notes
|
||||
func (a *App) GetNotes(userID int, params GetNotesParams) (GetNotesResult, error) {
|
||||
conn := getNotesBaseQuery(a.DB, userID, params)
|
||||
|
||||
var total int
|
||||
if err := conn.Model(database.Note{}).Count(&total).Error; err != nil {
|
||||
return GetNotesResult{}, errors.Wrap(err, "counting total")
|
||||
}
|
||||
|
||||
notes := []database.Note{}
|
||||
if total != 0 {
|
||||
conn = orderGetNotes(conn)
|
||||
conn = database.PreloadNote(conn)
|
||||
conn = paginate(conn, params.Page, params.PerPage)
|
||||
|
||||
if err := conn.Find(¬es).Error; err != nil {
|
||||
return GetNotesResult{}, errors.Wrap(err, "finding notes")
|
||||
}
|
||||
}
|
||||
|
||||
res := GetNotesResult{
|
||||
Notes: notes,
|
||||
Total: total,
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,9 +27,10 @@ import (
|
|||
"github.com/dnote/dnote/pkg/server/database"
|
||||
"github.com/dnote/dnote/pkg/server/testutils"
|
||||
"github.com/pkg/errors"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestCreateUser(t *testing.T) {
|
||||
func TestCreateUser_ProValue(t *testing.T) {
|
||||
testCases := []struct {
|
||||
onPremise bool
|
||||
expectedPro bool
|
||||
|
|
@ -54,7 +55,7 @@ func TestCreateUser(t *testing.T) {
|
|||
a := NewTest(&App{
|
||||
Config: c,
|
||||
})
|
||||
if _, err := a.CreateUser("alice@example.com", "pass1234"); err != nil {
|
||||
if _, err := a.CreateUser("alice@example.com", "pass1234", "pass1234"); err != nil {
|
||||
t.Fatal(errors.Wrap(err, "executing"))
|
||||
}
|
||||
|
||||
|
|
@ -68,3 +69,53 @@ func TestCreateUser(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateUser(t *testing.T) {
|
||||
t.Run("success", func(t *testing.T) {
|
||||
defer testutils.ClearData(testutils.DB)
|
||||
|
||||
c := config.Load()
|
||||
a := NewTest(&App{
|
||||
Config: c,
|
||||
})
|
||||
if _, err := a.CreateUser("alice@example.com", "pass1234", "pass1234"); err != nil {
|
||||
t.Fatal(errors.Wrap(err, "executing"))
|
||||
}
|
||||
|
||||
var userCount int
|
||||
testutils.MustExec(t, testutils.DB.Model(&database.User{}).Count(&userCount), "counting user")
|
||||
assert.Equal(t, userCount, 1, "book count mismatch")
|
||||
|
||||
var accountCount int
|
||||
var accountRecord database.Account
|
||||
testutils.MustExec(t, testutils.DB.Model(&database.Account{}).Count(&accountCount), "counting account")
|
||||
testutils.MustExec(t, testutils.DB.First(&accountRecord), "finding account")
|
||||
|
||||
assert.Equal(t, accountCount, 1, "account count mismatch")
|
||||
assert.Equal(t, accountRecord.Email.String, "alice@example.com", "account email mismatch")
|
||||
|
||||
passwordErr := bcrypt.CompareHashAndPassword([]byte(accountRecord.Password.String), []byte("pass1234"))
|
||||
assert.Equal(t, passwordErr, nil, "Password mismatch")
|
||||
})
|
||||
|
||||
t.Run("duplicate email", func(t *testing.T) {
|
||||
defer testutils.ClearData(testutils.DB)
|
||||
|
||||
aliceUser := database.User{}
|
||||
aliceAccount := database.Account{UserID: aliceUser.ID, Email: database.ToNullString("alice@example.com")}
|
||||
testutils.MustExec(t, testutils.DB.Save(&aliceUser), "preparing a user")
|
||||
testutils.MustExec(t, testutils.DB.Save(&aliceAccount), "preparing an account")
|
||||
|
||||
a := NewTest(nil)
|
||||
_, err := a.CreateUser("alice@example.com", "newpassword", "newpassword")
|
||||
|
||||
assert.Equal(t, err, ErrDuplicateEmail, "error mismatch")
|
||||
|
||||
var userCount, accountCount int
|
||||
testutils.MustExec(t, testutils.DB.Model(&database.User{}).Count(&userCount), "counting user")
|
||||
testutils.MustExec(t, testutils.DB.Model(&database.Account{}).Count(&accountCount), "counting account")
|
||||
|
||||
assert.Equal(t, userCount, 1, "user count mismatch")
|
||||
assert.Equal(t, accountCount, 1, "account count mismatch")
|
||||
})
|
||||
}
|
||||
|
|
|
|||
24
pkg/server/assets/js/build.sh
Executable file
|
|
@ -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
|
||||
59
pkg/server/assets/js/src/main.js
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
var getNextSibling = function (el, selector) {
|
||||
var sibling = el.nextElementSibling;
|
||||
|
||||
if (!selector) {
|
||||
return sibling;
|
||||
}
|
||||
|
||||
while (sibling) {
|
||||
if (sibling.matches(selector)) return sibling;
|
||||
sibling = sibling.nextElementSibling;
|
||||
}
|
||||
};
|
||||
|
||||
var dropdownTriggerEls = document.getElementsByClassName('dropdown-trigger');
|
||||
|
||||
for (var i = 0; i < dropdownTriggerEls.length; i++) {
|
||||
var dropdownTriggerEl = dropdownTriggerEls[i];
|
||||
|
||||
dropdownTriggerEl.addEventListener('click', function (e) {
|
||||
var el = getNextSibling(e.target, '.dropdown-content');
|
||||
|
||||
el.classList.toggle('show');
|
||||
});
|
||||
}
|
||||
|
||||
// Dropdown closer
|
||||
window.onclick = function (e) {
|
||||
// Close dropdown on click outside the dropdown content or trigger
|
||||
function shouldClose(target) {
|
||||
var dropdownContentEls = document.getElementsByClassName(
|
||||
'dropdown-content'
|
||||
);
|
||||
|
||||
for (let i = 0; i < dropdownContentEls.length; ++i) {
|
||||
var el = dropdownContentEls[i];
|
||||
if (el.contains(target)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < dropdownTriggerEls.length; ++i) {
|
||||
var el = dropdownTriggerEls[i];
|
||||
if (el.contains(target)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (shouldClose(e.target)) {
|
||||
var dropdowns = document.getElementsByClassName('dropdown-content');
|
||||
for (var i = 0; i < dropdowns.length; i++) {
|
||||
var openDropdown = dropdowns[i];
|
||||
if (openDropdown.classList.contains('show')) {
|
||||
openDropdown.classList.remove('show');
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
157
pkg/server/assets/package-lock.json
generated
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
12
pkg/server/assets/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
10
pkg/server/assets/static/500.html
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title></title>
|
||||
</head>
|
||||
<body>
|
||||
500
|
||||
</body>
|
||||
</html>
|
||||
BIN
pkg/server/assets/static/android-icon-144x144.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
pkg/server/assets/static/android-icon-192x192.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
pkg/server/assets/static/android-icon-36x36.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
pkg/server/assets/static/android-icon-48x48.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
pkg/server/assets/static/android-icon-72x72.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
pkg/server/assets/static/android-icon-96x96.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
pkg/server/assets/static/apple-icon-114x114.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
pkg/server/assets/static/apple-icon-120x120.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
pkg/server/assets/static/apple-icon-144x144.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
pkg/server/assets/static/apple-icon-152x152.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
pkg/server/assets/static/apple-icon-180x180.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
pkg/server/assets/static/apple-icon-57x57.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
pkg/server/assets/static/apple-icon-60x60.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
pkg/server/assets/static/apple-icon-72x72.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
pkg/server/assets/static/apple-icon-76x76.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
pkg/server/assets/static/apple-icon-precomposed.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
pkg/server/assets/static/apple-icon.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
2
pkg/server/assets/static/browserconfig.xml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<browserconfig><msapplication><tile><square70x70logo src="/ms-icon-70x70.png"/><square150x150logo src="/ms-icon-150x150.png"/><square310x310logo src="/ms-icon-310x310.png"/><TileColor>#ffffff</TileColor></tile></msapplication></browserconfig>
|
||||
BIN
pkg/server/assets/static/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 1 KiB |
BIN
pkg/server/assets/static/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
pkg/server/assets/static/favicon-96x96.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
pkg/server/assets/static/favicon.ico
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
pkg/server/assets/static/logo-512x512.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
52
pkg/server/assets/static/manifest.json
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
{
|
||||
"name": "Dnote",
|
||||
"short_name": "Dnote",
|
||||
"icons": [
|
||||
{
|
||||
"src": "ASSET_BASE_PLACEHOLDER\/android-icon-36x36.png",
|
||||
"sizes": "36x36",
|
||||
"type": "image\/png",
|
||||
"density": "0.75"
|
||||
},
|
||||
{
|
||||
"src": "ASSET_BASE_PLACEHOLDER\/android-icon-48x48.png",
|
||||
"sizes": "48x48",
|
||||
"type": "image\/png",
|
||||
"density": "1.0"
|
||||
},
|
||||
{
|
||||
"src": "ASSET_BASE_PLACEHOLDER\/android-icon-72x72.png",
|
||||
"sizes": "72x72",
|
||||
"type": "image\/png",
|
||||
"density": "1.5"
|
||||
},
|
||||
{
|
||||
"src": "ASSET_BASE_PLACEHOLDER\/android-icon-96x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image\/png",
|
||||
"density": "2.0"
|
||||
},
|
||||
{
|
||||
"src": "ASSET_BASE_PLACEHOLDER\/android-icon-144x144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image\/png",
|
||||
"density": "3.0"
|
||||
},
|
||||
{
|
||||
"src": "ASSET_BASE_PLACEHOLDER\/android-icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image\/png",
|
||||
"density": "4.0"
|
||||
},
|
||||
{
|
||||
"src": "ASSET_BASE_PLACEHOLDER\/logo-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image\/png",
|
||||
"density": "4.0"
|
||||
}
|
||||
],
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#072a40",
|
||||
"theme_color": "#ffffff"
|
||||
}
|
||||
BIN
pkg/server/assets/static/ms-icon-144x144.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
pkg/server/assets/static/ms-icon-150x150.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
pkg/server/assets/static/ms-icon-310x310.png
Normal file
|
After Width: | Height: | Size: 7.8 KiB |
BIN
pkg/server/assets/static/ms-icon-70x70.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
41
pkg/server/assets/static/offline.html
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
|
||||
<title>Page Not Found | Dnote</title>
|
||||
<style type="text/css">
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
main {
|
||||
text-align: center;
|
||||
padding: 8em 0.5em;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 207px;
|
||||
}
|
||||
|
||||
.contact-list {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.contact-list li {
|
||||
display: inline-block;
|
||||
padding: 0 0.7em;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>You are offline</h1>
|
||||
<p>
|
||||
Please check you connection and try again.
|
||||
</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
24
pkg/server/assets/styles/build.sh
Executable file
|
|
@ -0,0 +1,24 @@
|
|||
#!/usr/bin/env bash
|
||||
# build.sh builds styles
|
||||
set -ex
|
||||
|
||||
dir=$(dirname "${BASH_SOURCE[0]}")
|
||||
serverDir="$dir/../.."
|
||||
outputDir="$serverDir/static"
|
||||
inputDir="$dir/src"
|
||||
|
||||
rm -rf "${outputDir:?}/*"
|
||||
|
||||
"$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
|
||||
11
pkg/server/assets/styles/src/_books.scss
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
.books-page {
|
||||
.books-content {
|
||||
padding: rem(16px) rem(24px);
|
||||
margin-top: rem(16px);
|
||||
|
||||
h1 {
|
||||
border-bottom: 1px solid $lighter-gray;
|
||||
margin-bottom: rem(12px);
|
||||
}
|
||||
}
|
||||
}
|
||||
176
pkg/server/assets/styles/src/_bootstrap.scss
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
/* Copyright (C) 2019, 2020 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
// From Bootstrap <4.3.1
|
||||
// MIT Licensed - https://github.com/twbs/bootstrap/blob/master/LICENSE
|
||||
.form-control {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
color: #495057;
|
||||
background-color: #fff;
|
||||
background-clip: padding-box;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 0.25rem;
|
||||
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.alert {
|
||||
position: relative;
|
||||
padding: 1.75rem 1.25rem;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.alert-heading {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.alert-link {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.alert-dismissible {
|
||||
// padding-right: 4rem;
|
||||
}
|
||||
|
||||
.alert-dismissible .close {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
padding: 0.75rem 1.25rem;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.alert-primary {
|
||||
color: #004085;
|
||||
background-color: #cce5ff;
|
||||
border-color: #b8daff;
|
||||
}
|
||||
|
||||
.alert-primary hr {
|
||||
border-top-color: #9fcdff;
|
||||
}
|
||||
|
||||
.alert-primary .alert-link {
|
||||
color: #002752;
|
||||
}
|
||||
|
||||
.alert-secondary {
|
||||
color: #383d41;
|
||||
background-color: #e2e3e5;
|
||||
border-color: #d6d8db;
|
||||
}
|
||||
|
||||
.alert-secondary hr {
|
||||
border-top-color: #c8cbcf;
|
||||
}
|
||||
|
||||
.alert-secondary .alert-link {
|
||||
color: #202326;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
color: #155724;
|
||||
background-color: #d4edda;
|
||||
border-color: #c3e6cb;
|
||||
}
|
||||
|
||||
.alert-success hr {
|
||||
border-top-color: #b1dfbb;
|
||||
}
|
||||
|
||||
.alert-success .alert-link {
|
||||
color: #0b2e13;
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
color: #0c5460;
|
||||
background-color: #d1ecf1;
|
||||
border-color: #bee5eb;
|
||||
}
|
||||
|
||||
.alert-info hr {
|
||||
border-top-color: #abdde5;
|
||||
}
|
||||
|
||||
.alert-info .alert-link {
|
||||
color: #062c33;
|
||||
}
|
||||
|
||||
.alert-warning {
|
||||
color: #856404;
|
||||
background-color: #fff3cd;
|
||||
border-color: #ffeeba;
|
||||
}
|
||||
|
||||
.alert-warning hr {
|
||||
border-top-color: #ffe8a1;
|
||||
}
|
||||
|
||||
.alert-warning .alert-link {
|
||||
color: #533f03;
|
||||
}
|
||||
|
||||
.alert-danger {
|
||||
color: #721c24;
|
||||
background-color: #f8d7da;
|
||||
border-color: #f5c6cb;
|
||||
}
|
||||
|
||||
.alert-danger hr {
|
||||
border-top-color: #f1b0b7;
|
||||
}
|
||||
|
||||
.alert-danger .alert-link {
|
||||
color: #491217;
|
||||
}
|
||||
|
||||
.alert-light {
|
||||
color: #818182;
|
||||
background-color: #fefefe;
|
||||
border-color: #fdfdfe;
|
||||
}
|
||||
|
||||
.alert-light hr {
|
||||
border-top-color: #ececf6;
|
||||
}
|
||||
|
||||
.alert-light .alert-link {
|
||||
color: #686868;
|
||||
}
|
||||
|
||||
.alert-dark {
|
||||
color: #1b1e21;
|
||||
background-color: #d6d8d9;
|
||||
border-color: #c6c8ca;
|
||||
}
|
||||
|
||||
.alert-dark hr {
|
||||
border-top-color: #b9bbbe;
|
||||
}
|
||||
|
||||
.alert-dark .alert-link {
|
||||
color: #040505;
|
||||
}
|
||||
|
||||
// custom
|
||||
.alert-slim {
|
||||
padding: 0.75rem 1.25rem;
|
||||
}
|
||||
182
pkg/server/assets/styles/src/_buttons.scss
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
/* Copyright (C) 2019, 2020 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@import './theme';
|
||||
@import './rem';
|
||||
@import './font';
|
||||
|
||||
@mixin button($text-color, $background-color) {
|
||||
color: $text-color;
|
||||
background-color: $background-color;
|
||||
|
||||
&:not(:disabled):hover {
|
||||
color: $text-color;
|
||||
background-color: darken($background-color, 5%);
|
||||
box-shadow: 0px 0px 4px 2px #cacaca;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin button-outline($color, $border-color) {
|
||||
background: transparent;
|
||||
color: $color;
|
||||
|
||||
&:not(.button-no-ui) {
|
||||
border-color: $border-color;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
&:not(:disabled):hover {
|
||||
color: $color;
|
||||
box-shadow: 0px 0px 4px 2px #cacaca;
|
||||
}
|
||||
}
|
||||
|
||||
.button {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
user-select: none;
|
||||
border-image: initial;
|
||||
transition-property: color, box-shadow;
|
||||
transition-duration: 0.2s;
|
||||
transition-timing-function: ease-in-out;
|
||||
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
|
||||
&:not(.button-no-ui) {
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
&:not(:disabled):hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 2px dotted #9c9c9c;
|
||||
}
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.button-small {
|
||||
@include font-size('small');
|
||||
padding: rem(4px) rem(12px);
|
||||
}
|
||||
|
||||
.button-normal {
|
||||
// @include font-size('small');
|
||||
padding: rem(8px) rem(16px);
|
||||
}
|
||||
|
||||
.button-large {
|
||||
@include font-size('medium');
|
||||
|
||||
padding: rem(8px) rem(24px);
|
||||
|
||||
@include breakpoint(md) {
|
||||
padding: rem(12px) rem(36px);
|
||||
}
|
||||
|
||||
@include breakpoint(lg) {
|
||||
padding: rem(12px) rem(48px);
|
||||
}
|
||||
}
|
||||
|
||||
.button-xlarge {
|
||||
@include font-size('x-large');
|
||||
|
||||
padding: rem(16px) rem(24px);
|
||||
|
||||
@include breakpoint(md) {
|
||||
padding: rem(12px) rem(36px);
|
||||
}
|
||||
|
||||
@include breakpoint(lg) {
|
||||
padding: rem(16px) rem(48px);
|
||||
}
|
||||
}
|
||||
|
||||
.button-first {
|
||||
@include button(#ffffff, #333745);
|
||||
}
|
||||
|
||||
.button-first-outline {
|
||||
@include button-outline(#333745, #333745);
|
||||
}
|
||||
|
||||
.button-second {
|
||||
@include button($black, $second);
|
||||
}
|
||||
|
||||
.button-second-outline {
|
||||
@include button-outline($black, $second);
|
||||
}
|
||||
|
||||
.button-third {
|
||||
@include button(#ffffff, $third);
|
||||
}
|
||||
|
||||
.button-third-outline {
|
||||
@include button-outline($third, $third);
|
||||
}
|
||||
|
||||
.button-danger {
|
||||
@include button-outline($danger-text, $danger-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.button-stretch {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.button ~ .button {
|
||||
margin-left: rem(12px);
|
||||
}
|
||||
|
||||
.button-no-ui {
|
||||
border: none;
|
||||
background: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.button-no-padding {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.button-link {
|
||||
color: $link;
|
||||
|
||||
&:hover {
|
||||
color: $link-hover;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
111
pkg/server/assets/styles/src/_font.scss
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
/* Copyright (C) 2019, 2020 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@import './responsive';
|
||||
|
||||
$lowDecay: 0.1;
|
||||
$medDecay: 0.15;
|
||||
$highDecay: 0.2;
|
||||
|
||||
// font-size is a mixin for pre-defined font-size values in rem.
|
||||
// It also includes px as a fallback for older browsers.
|
||||
@mixin font-size($size, $responsive: true) {
|
||||
$smSizeValue: 16;
|
||||
$mdSizeValue: 16;
|
||||
$lgSizeValue: 16;
|
||||
|
||||
@if $size == 'x-small' {
|
||||
$baseSize: 13;
|
||||
|
||||
$smSizeValue: $baseSize;
|
||||
$mdSizeValue: $baseSize;
|
||||
$lgSizeValue: $baseSize;
|
||||
} @else if $size == 'small' {
|
||||
$baseSize: 14;
|
||||
|
||||
$smSizeValue: $baseSize;
|
||||
$mdSizeValue: $baseSize;
|
||||
$lgSizeValue: $baseSize;
|
||||
} @else if $size == 'regular' {
|
||||
$baseSize: 16;
|
||||
|
||||
$smSizeValue: $baseSize * (1 - $lowDecay);
|
||||
$mdSizeValue: $baseSize * (1 - $lowDecay);
|
||||
$lgSizeValue: $baseSize;
|
||||
} @else if $size == 'medium' {
|
||||
$baseSize: 18;
|
||||
|
||||
$smSizeValue: $baseSize;
|
||||
$mdSizeValue: $baseSize;
|
||||
$lgSizeValue: $baseSize;
|
||||
} @else if $size == 'large' {
|
||||
$baseSize: 20;
|
||||
|
||||
$smSizeValue: $baseSize;
|
||||
$mdSizeValue: $baseSize;
|
||||
$lgSizeValue: $baseSize;
|
||||
} @else if $size == 'x-large' {
|
||||
$baseSize: 24;
|
||||
|
||||
$smSizeValue: $baseSize * (1 - $lowDecay * 2);
|
||||
$mdSizeValue: $baseSize * (1 - $lowDecay);
|
||||
$lgSizeValue: $baseSize;
|
||||
} @else if $size == '2x-large' {
|
||||
$baseSize: 32;
|
||||
|
||||
$smSizeValue: $baseSize * (1 - $lowDecay * 2);
|
||||
$mdSizeValue: $baseSize * (1 - $lowDecay);
|
||||
$lgSizeValue: $baseSize;
|
||||
} @else if $size == '3x-large' {
|
||||
$baseSize: 36;
|
||||
|
||||
$smSizeValue: $baseSize * (1 - $medDecay * 2);
|
||||
$mdSizeValue: $baseSize * (1 - $medDecay);
|
||||
$lgSizeValue: $baseSize;
|
||||
} @else if $size == '4x-large' {
|
||||
$baseSize: 48;
|
||||
|
||||
$smSizeValue: $baseSize * (1 - $medDecay * 2);
|
||||
$mdSizeValue: $baseSize * (1 - $medDecay);
|
||||
$lgSizeValue: $baseSize;
|
||||
} @else if $size == '5x-large' {
|
||||
$baseSize: 56;
|
||||
|
||||
$smSizeValue: $baseSize * (1 - $highDecay * 2);
|
||||
$mdSizeValue: $baseSize * (1 - $highDecay);
|
||||
$lgSizeValue: $baseSize;
|
||||
}
|
||||
|
||||
@if $responsive == true {
|
||||
font-size: $smSizeValue * 1px;
|
||||
font-size: $smSizeValue * 0.1rem;
|
||||
|
||||
@include breakpoint(md) {
|
||||
font-size: $mdSizeValue * 1px;
|
||||
font-size: $mdSizeValue * 0.1rem;
|
||||
}
|
||||
|
||||
@include breakpoint(lg) {
|
||||
font-size: $lgSizeValue * 1px;
|
||||
font-size: $lgSizeValue * 0.1rem;
|
||||
}
|
||||
} @else {
|
||||
font-size: $lgSizeValue * 1px;
|
||||
font-size: $lgSizeValue * 0.1rem;
|
||||
}
|
||||
}
|
||||
85
pkg/server/assets/styles/src/_global.scss
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
.main {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: $lighter-gray;
|
||||
min-height: calc(100vh - #{$header-height});
|
||||
// margin-bottom: $footer-height;
|
||||
|
||||
&.nofooter {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
&.noheader:not(.nofooter) {
|
||||
min-height: calc(100vh - #{$footer-height});
|
||||
}
|
||||
&.nofooter:not(.noheader) {
|
||||
min-height: calc(100vh - #{$header-height});
|
||||
}
|
||||
&.nofooter.noheader {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
@include breakpoint(lg) {
|
||||
margin-bottom: 0;
|
||||
min-height: calc(100vh - #{$header-height});
|
||||
}
|
||||
}
|
||||
|
||||
/* partials */
|
||||
.partial--time {
|
||||
color: $gray;
|
||||
@include font-size('small');
|
||||
|
||||
.mobile-text {
|
||||
@include breakpoint(md) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.text {
|
||||
display: none;
|
||||
|
||||
@include breakpoint(md) {
|
||||
display: inherit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.partial--page-toolbar {
|
||||
@include breakpoint(lg) {
|
||||
height: rem(48px);
|
||||
border-radius: rem(4px);
|
||||
background: $light;
|
||||
box-shadow: 0 0 8px rgba(0, 0, 0, 0.14);
|
||||
|
||||
&.bottom {
|
||||
margin-top: rem(12px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* icons */
|
||||
.icon--caret-right {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.icon--caret-left {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
// was originally used in note show
|
||||
.frame {
|
||||
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.2);
|
||||
background: white;
|
||||
|
||||
&.collapsed {
|
||||
.book-label {
|
||||
// control the coloro of ellipsis when overflown
|
||||
// color: $light-gray;
|
||||
}
|
||||
|
||||
.book-label a {
|
||||
// color: $light-gray;
|
||||
}
|
||||
}
|
||||
}
|
||||
1108
pkg/server/assets/styles/src/_grid.scss
Normal file
192
pkg/server/assets/styles/src/_header.scss
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
@import './theme';
|
||||
@import './variables';
|
||||
|
||||
.header-wrapper {
|
||||
padding: 0;
|
||||
z-index: 2;
|
||||
position: relative;
|
||||
display: flex;
|
||||
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.2);
|
||||
background: $first;
|
||||
align-items: stretch;
|
||||
justify-content: space-between;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 4;
|
||||
height: $header-height;
|
||||
|
||||
.container {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@include breakpoint(md) {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.left {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.right {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.search-wrapper {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
margin-left: rem(32px);
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: rem(356px);
|
||||
border: 0;
|
||||
padding: 4px 12px;
|
||||
border-radius: rem(4px);
|
||||
@include font-size('small');
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
.main-nav {
|
||||
margin-left: rem(32px);
|
||||
display: flex;
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
@include font-size('small');
|
||||
display: flex;
|
||||
font-weight: 600;
|
||||
align-items: center;
|
||||
padding: 0 rem(16px);
|
||||
color: $white;
|
||||
|
||||
&:hover {
|
||||
color: $white;
|
||||
text-decoration: none;
|
||||
background: lighten($first, 10%);
|
||||
}
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
@include font-size('small');
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-trigger {
|
||||
color: white;
|
||||
padding: 16px;
|
||||
font-size: 16px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.dropdown-content {
|
||||
display: none;
|
||||
position: absolute;
|
||||
background-color: #f1f1f1;
|
||||
width: rem(240px);
|
||||
background: #fff;
|
||||
border: 1px solid #d8d8d8;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 0 3px rgba(0, 0, 0, 0.15);
|
||||
top: calc(100% + 4px);
|
||||
z-index: 1;
|
||||
|
||||
&.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
&.right-align {
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.account-dropdown {
|
||||
.dropdown-trigger {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.account-dropdown-header {
|
||||
@include font-size('small');
|
||||
color: $light-gray;
|
||||
padding: rem(8px) rem(12px);
|
||||
display: block;
|
||||
margin-bottom: 0;
|
||||
white-space: nowrap;
|
||||
|
||||
svg {
|
||||
fill: $light-gray;
|
||||
}
|
||||
|
||||
.email {
|
||||
font-weight: 600;
|
||||
white-space: normal;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-link {
|
||||
@include font-size('small');
|
||||
white-space: pre;
|
||||
padding: rem(8px) rem(14px);
|
||||
width: 100%;
|
||||
display: block;
|
||||
color: black;
|
||||
|
||||
&:hover {
|
||||
background: $lighter-gray;
|
||||
text-decoration: none;
|
||||
color: #0056b3;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
color: #d4d4d4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&:not(.disabled):focus {
|
||||
background: $lighter-gray;
|
||||
color: #0056b3;
|
||||
outline: 1px dotted gray;
|
||||
}
|
||||
}
|
||||
|
||||
.session-notice-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.session-notice {
|
||||
margin-left: rem(4px);
|
||||
}
|
||||
}
|
||||
}
|
||||
147
pkg/server/assets/styles/src/_hljs.scss
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
/* Copyright (C) 2019, 2020 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/*
|
||||
highlight.js
|
||||
|
||||
BSD 3-Clause License
|
||||
|
||||
Copyright (c) 2006, Ivan Sagalaev.
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
* Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
* Neither the name of the copyright holder nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
*/
|
||||
|
||||
// github style
|
||||
|
||||
.hljs {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
padding: 0.5em;
|
||||
color: #333;
|
||||
background: #f8f8f8;
|
||||
}
|
||||
|
||||
.hljs-comment,
|
||||
.hljs-quote {
|
||||
color: #998;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.hljs-keyword,
|
||||
.hljs-selector-tag,
|
||||
.hljs-subst {
|
||||
color: #333;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.hljs-number,
|
||||
.hljs-literal,
|
||||
.hljs-variable,
|
||||
.hljs-template-variable,
|
||||
.hljs-tag .hljs-attr {
|
||||
color: #008080;
|
||||
}
|
||||
|
||||
.hljs-string,
|
||||
.hljs-doctag {
|
||||
color: #d14;
|
||||
}
|
||||
|
||||
.hljs-title,
|
||||
.hljs-section,
|
||||
.hljs-selector-id {
|
||||
color: #900;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.hljs-subst {
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.hljs-type,
|
||||
.hljs-class .hljs-title {
|
||||
color: #458;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.hljs-tag,
|
||||
.hljs-name,
|
||||
.hljs-attribute {
|
||||
color: #000080;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.hljs-regexp,
|
||||
.hljs-link {
|
||||
color: #009926;
|
||||
}
|
||||
|
||||
.hljs-symbol,
|
||||
.hljs-bullet {
|
||||
color: #990073;
|
||||
}
|
||||
|
||||
.hljs-built_in,
|
||||
.hljs-builtin-name {
|
||||
color: #0086b3;
|
||||
}
|
||||
|
||||
.hljs-meta {
|
||||
color: #999;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.hljs-deletion {
|
||||
background: #fdd;
|
||||
}
|
||||
|
||||
.hljs-addition {
|
||||
background: #dfd;
|
||||
}
|
||||
|
||||
.hljs-emphasis {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.hljs-strong {
|
||||
font-weight: bold;
|
||||
}
|
||||
185
pkg/server/assets/styles/src/_home.scss
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
@import './theme';
|
||||
@import './font';
|
||||
|
||||
.home-page {
|
||||
.note-group-list {
|
||||
flex-grow: 1;
|
||||
|
||||
@include breakpoint(lg) {
|
||||
margin-top: rem(16px);
|
||||
}
|
||||
|
||||
.note-group-list-empty {
|
||||
padding: rem(40px) rem(16px);
|
||||
text-align: center;
|
||||
color: $gray;
|
||||
}
|
||||
}
|
||||
|
||||
.note-group {
|
||||
position: relative;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 0 8px rgba(0, 0, 0, 0.14);
|
||||
|
||||
&:not(:first-of-type) {
|
||||
margin-top: rem(20px);
|
||||
|
||||
@include breakpoint(md) {
|
||||
margin-top: rem(24px);
|
||||
}
|
||||
}
|
||||
|
||||
.note-group-header {
|
||||
@include font-size('small');
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: white;
|
||||
padding: rem(12px) rem(16px);
|
||||
background: $light;
|
||||
color: $black;
|
||||
border-bottom: 1px solid $border-color;
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
|
||||
.date {
|
||||
font-weight: 600;
|
||||
@include font-size('small');
|
||||
}
|
||||
|
||||
.mask {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: white;
|
||||
z-index: 1;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.header-date {
|
||||
font-weight: 600;
|
||||
@include font-size('regular');
|
||||
}
|
||||
.header-count {
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.note-list {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.note-item {
|
||||
background: white;
|
||||
position: relative;
|
||||
|
||||
border-bottom: 1px solid $border-color;
|
||||
|
||||
.link {
|
||||
color: $black;
|
||||
display: block;
|
||||
padding: rem(12px) rem(16px);
|
||||
border: 2px solid transparent;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
background: $light-blue;
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.meta {
|
||||
line-height: rem(16px);
|
||||
}
|
||||
|
||||
.body {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.note-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.note-content {
|
||||
margin-top: rem(12px);
|
||||
line-height: 1.6rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: $gray;
|
||||
}
|
||||
|
||||
.book-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 700;
|
||||
@include font-size('small');
|
||||
|
||||
width: 212px;
|
||||
|
||||
@include breakpoint('md') {
|
||||
width: 320px;
|
||||
}
|
||||
}
|
||||
|
||||
.match {
|
||||
display: inline-block;
|
||||
background: #f7f77d;
|
||||
padding: rem(4px) rem(4px);
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.paginator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
.paginator-info {
|
||||
@include font-size('small');
|
||||
color: $gray;
|
||||
}
|
||||
|
||||
.paginator-link {
|
||||
padding: rem(12px) rem(12px);
|
||||
|
||||
&.disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.paginator-link-prev {
|
||||
margin-left: rem(8px);
|
||||
|
||||
@include breakpoint(md) {
|
||||
margin-left: rem(20px);
|
||||
}
|
||||
}
|
||||
|
||||
.caret-next {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.caret-prev {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.paginator-label {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
88
pkg/server/assets/styles/src/_login.scss
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
@import './theme';
|
||||
@import './font';
|
||||
|
||||
.auth-page {
|
||||
background: $lighter-gray;
|
||||
text-align: center;
|
||||
min-height: 100vh;
|
||||
padding: 50px 0;
|
||||
|
||||
.auth-button {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.heading {
|
||||
color: $black;
|
||||
@include font-size('2x-large');
|
||||
font-weight: 300;
|
||||
margin-top: 12px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.body {
|
||||
max-width: 420px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.referrer-flash {
|
||||
margin: 24px 0;
|
||||
}
|
||||
.error-flash {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 20px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.callout {
|
||||
color: #7c7c7c;
|
||||
@include font-size('small');
|
||||
}
|
||||
.cta {
|
||||
@include font-size('small');
|
||||
}
|
||||
|
||||
.panel {
|
||||
border: 1px solid $border-color;
|
||||
background: $white;
|
||||
border-radius: 2px;
|
||||
padding: 20px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.auth-button {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.input-row {
|
||||
& ~ .input-row {
|
||||
margin-top: 12px;
|
||||
}
|
||||
}
|
||||
.label {
|
||||
@include font-size('small');
|
||||
font-weight: 600;
|
||||
width: 100%;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.forgot {
|
||||
@include font-size('small');
|
||||
float: right;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
&.password-reset-page {
|
||||
.email-input {
|
||||
margin-top: rem(16px);
|
||||
}
|
||||
}
|
||||
|
||||
.alert {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
966
pkg/server/assets/styles/src/_markdown.scss
Normal file
|
|
@ -0,0 +1,966 @@
|
|||
/* Copyright (C) 2019, 2020 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/*
|
||||
MIT License
|
||||
|
||||
Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (sindresorhus.com)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
.markdown-body .anchor {
|
||||
float: left;
|
||||
line-height: 1;
|
||||
margin-left: -20px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.markdown-body .anchor:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.markdown-body h1:hover .anchor,
|
||||
.markdown-body h2:hover .anchor,
|
||||
.markdown-body h3:hover .anchor,
|
||||
.markdown-body h4:hover .anchor,
|
||||
.markdown-body h5:hover .anchor,
|
||||
.markdown-body h6:hover .anchor {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.markdown-body {
|
||||
-ms-text-size-adjust: 100%;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
color: #24292e;
|
||||
line-height: 1.5;
|
||||
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Helvetica, Arial,
|
||||
sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.markdown-body .pl-c {
|
||||
color: #6a737d;
|
||||
}
|
||||
|
||||
.markdown-body .pl-c1,
|
||||
.markdown-body .pl-s .pl-v {
|
||||
color: #005cc5;
|
||||
}
|
||||
|
||||
.markdown-body .pl-e,
|
||||
.markdown-body .pl-en {
|
||||
color: #6f42c1;
|
||||
}
|
||||
|
||||
.markdown-body .pl-s .pl-s1,
|
||||
.markdown-body .pl-smi {
|
||||
color: #24292e;
|
||||
}
|
||||
|
||||
.markdown-body .pl-ent {
|
||||
color: #22863a;
|
||||
}
|
||||
|
||||
.markdown-body .pl-k {
|
||||
color: #d73a49;
|
||||
}
|
||||
|
||||
.markdown-body .pl-pds,
|
||||
.markdown-body .pl-s,
|
||||
.markdown-body .pl-s .pl-pse .pl-s1,
|
||||
.markdown-body .pl-sr,
|
||||
.markdown-body .pl-sr .pl-cce,
|
||||
.markdown-body .pl-sr .pl-sra,
|
||||
.markdown-body .pl-sr .pl-sre {
|
||||
color: #032f62;
|
||||
}
|
||||
|
||||
.markdown-body .pl-smw,
|
||||
.markdown-body .pl-v {
|
||||
color: #e36209;
|
||||
}
|
||||
|
||||
.markdown-body .pl-bu {
|
||||
color: #b31d28;
|
||||
}
|
||||
|
||||
.markdown-body .pl-ii {
|
||||
background-color: #b31d28;
|
||||
color: #fafbfc;
|
||||
}
|
||||
|
||||
.markdown-body .pl-c2 {
|
||||
background-color: #d73a49;
|
||||
color: #fafbfc;
|
||||
}
|
||||
|
||||
.markdown-body .pl-c2:before {
|
||||
content: '^M';
|
||||
}
|
||||
|
||||
.markdown-body .pl-sr .pl-cce {
|
||||
color: #22863a;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.markdown-body .pl-ml {
|
||||
color: #735c0f;
|
||||
}
|
||||
|
||||
.markdown-body .pl-mh,
|
||||
.markdown-body .pl-mh .pl-en,
|
||||
.markdown-body .pl-ms {
|
||||
color: #005cc5;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.markdown-body .pl-mi {
|
||||
color: #24292e;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.markdown-body .pl-mb {
|
||||
color: #24292e;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.markdown-body .pl-md {
|
||||
background-color: #ffeef0;
|
||||
color: #b31d28;
|
||||
}
|
||||
|
||||
.markdown-body .pl-mi1 {
|
||||
background-color: #f0fff4;
|
||||
color: #22863a;
|
||||
}
|
||||
|
||||
.markdown-body .pl-mc {
|
||||
background-color: #ffebda;
|
||||
color: #e36209;
|
||||
}
|
||||
|
||||
.markdown-body .pl-mi2 {
|
||||
background-color: #005cc5;
|
||||
color: #f6f8fa;
|
||||
}
|
||||
|
||||
.markdown-body .pl-mdr {
|
||||
color: #6f42c1;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.markdown-body .pl-ba {
|
||||
color: #586069;
|
||||
}
|
||||
|
||||
.markdown-body .pl-sg {
|
||||
color: #959da5;
|
||||
}
|
||||
|
||||
.markdown-body .pl-corl {
|
||||
color: #032f62;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.markdown-body details {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.markdown-body summary {
|
||||
display: list-item;
|
||||
}
|
||||
|
||||
.markdown-body a {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.markdown-body a:active,
|
||||
.markdown-body a:hover {
|
||||
outline-width: 0;
|
||||
}
|
||||
|
||||
.markdown-body strong {
|
||||
font-weight: inherit;
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
.markdown-body h1 {
|
||||
font-size: 2em;
|
||||
margin: 0.67em 0;
|
||||
}
|
||||
|
||||
.markdown-body img {
|
||||
border-style: none;
|
||||
}
|
||||
|
||||
.markdown-body code,
|
||||
.markdown-body kbd,
|
||||
.markdown-body pre {
|
||||
font-family: monospace, monospace;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.markdown-body hr {
|
||||
box-sizing: content-box;
|
||||
height: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.markdown-body input {
|
||||
font: inherit;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.markdown-body input {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.markdown-body [type='checkbox'] {
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.markdown-body * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.markdown-body input {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.markdown-body a {
|
||||
color: #0366d6;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.markdown-body a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.markdown-body strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.markdown-body hr {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-bottom: 1px solid #dfe2e5;
|
||||
height: 0;
|
||||
margin: 15px 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.markdown-body hr:before {
|
||||
content: '';
|
||||
display: table;
|
||||
}
|
||||
|
||||
.markdown-body hr:after {
|
||||
clear: both;
|
||||
content: '';
|
||||
display: table;
|
||||
}
|
||||
|
||||
.markdown-body table {
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
.markdown-body td,
|
||||
.markdown-body th {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.markdown-body details summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.markdown-body h1,
|
||||
.markdown-body h2,
|
||||
.markdown-body h3,
|
||||
.markdown-body h4,
|
||||
.markdown-body h5,
|
||||
.markdown-body h6 {
|
||||
margin-bottom: 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.markdown-body h1 {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.markdown-body h1,
|
||||
.markdown-body h2 {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.markdown-body h2 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.markdown-body h3 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.markdown-body h3,
|
||||
.markdown-body h4 {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.markdown-body h4 {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.markdown-body h5 {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.markdown-body h5,
|
||||
.markdown-body h6 {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.markdown-body h6 {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.markdown-body p {
|
||||
margin-bottom: 10px;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.markdown-body blockquote {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.markdown-body ol,
|
||||
.markdown-body ul {
|
||||
margin-bottom: 0;
|
||||
margin-top: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.markdown-body ol ol,
|
||||
.markdown-body ul ol {
|
||||
list-style-type: lower-roman;
|
||||
}
|
||||
|
||||
.markdown-body ol ol ol,
|
||||
.markdown-body ol ul ol,
|
||||
.markdown-body ul ol ol,
|
||||
.markdown-body ul ul ol {
|
||||
list-style-type: lower-alpha;
|
||||
}
|
||||
|
||||
.markdown-body dd {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.markdown-body code,
|
||||
.markdown-body pre {
|
||||
font-family: SFMono-Regular, Consolas, Liberation Mono, Menlo, Courier,
|
||||
monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.markdown-body pre {
|
||||
margin-bottom: 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.markdown-body input::-webkit-inner-spin-button,
|
||||
.markdown-body input::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.markdown-body .border {
|
||||
border: 1px solid #e1e4e8 !important;
|
||||
}
|
||||
|
||||
.markdown-body .border-0 {
|
||||
border: 0 !important;
|
||||
}
|
||||
|
||||
.markdown-body .border-bottom {
|
||||
border-bottom: 1px solid #e1e4e8 !important;
|
||||
}
|
||||
|
||||
.markdown-body .rounded-1 {
|
||||
border-radius: 3px !important;
|
||||
}
|
||||
|
||||
.markdown-body .bg-white {
|
||||
background-color: #fff !important;
|
||||
}
|
||||
|
||||
.markdown-body .bg-gray-light {
|
||||
background-color: #fafbfc !important;
|
||||
}
|
||||
|
||||
.markdown-body .text-gray-light {
|
||||
color: #6a737d !important;
|
||||
}
|
||||
|
||||
.markdown-body .mb-0 {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.markdown-body .my-2 {
|
||||
margin-bottom: 8px !important;
|
||||
margin-top: 8px !important;
|
||||
}
|
||||
|
||||
.markdown-body .pl-0 {
|
||||
padding-left: 0 !important;
|
||||
}
|
||||
|
||||
.markdown-body .py-0 {
|
||||
padding-bottom: 0 !important;
|
||||
padding-top: 0 !important;
|
||||
}
|
||||
|
||||
.markdown-body .pl-1 {
|
||||
padding-left: 4px !important;
|
||||
}
|
||||
|
||||
.markdown-body .pl-2 {
|
||||
padding-left: 8px !important;
|
||||
}
|
||||
|
||||
.markdown-body .py-2 {
|
||||
padding-bottom: 8px !important;
|
||||
padding-top: 8px !important;
|
||||
}
|
||||
|
||||
.markdown-body .pl-3,
|
||||
.markdown-body .px-3 {
|
||||
padding-left: 16px !important;
|
||||
}
|
||||
|
||||
.markdown-body .px-3 {
|
||||
padding-right: 16px !important;
|
||||
}
|
||||
|
||||
.markdown-body .pl-4 {
|
||||
padding-left: 24px !important;
|
||||
}
|
||||
|
||||
.markdown-body .pl-5 {
|
||||
padding-left: 32px !important;
|
||||
}
|
||||
|
||||
.markdown-body .pl-6 {
|
||||
padding-left: 40px !important;
|
||||
}
|
||||
|
||||
.markdown-body .f6 {
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
.markdown-body .lh-condensed {
|
||||
line-height: 1.25 !important;
|
||||
}
|
||||
|
||||
.markdown-body .text-bold {
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
.markdown-body:before {
|
||||
content: '';
|
||||
display: table;
|
||||
}
|
||||
|
||||
.markdown-body:after {
|
||||
clear: both;
|
||||
content: '';
|
||||
display: table;
|
||||
}
|
||||
|
||||
.markdown-body > :first-child {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
.markdown-body > :last-child {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.markdown-body a:not([href]) {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.markdown-body blockquote,
|
||||
.markdown-body dl,
|
||||
.markdown-body ol,
|
||||
.markdown-body p,
|
||||
.markdown-body pre,
|
||||
.markdown-body table,
|
||||
.markdown-body ul {
|
||||
margin-bottom: 16px;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.markdown-body hr {
|
||||
background-color: #e1e4e8;
|
||||
border: 0;
|
||||
height: 0.25em;
|
||||
margin: 24px 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.markdown-body blockquote {
|
||||
border-left: 0.25em solid #dfe2e5;
|
||||
color: #6a737d;
|
||||
padding: 0 1em;
|
||||
}
|
||||
|
||||
.markdown-body blockquote > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.markdown-body blockquote > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.markdown-body kbd {
|
||||
background-color: #fafbfc;
|
||||
border: 1px solid #c6cbd1;
|
||||
border-bottom-color: #959da5;
|
||||
border-radius: 3px;
|
||||
box-shadow: inset 0 -1px 0 #959da5;
|
||||
color: #444d56;
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
line-height: 10px;
|
||||
padding: 3px 5px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.markdown-body h1,
|
||||
.markdown-body h2,
|
||||
.markdown-body h3,
|
||||
.markdown-body h4,
|
||||
.markdown-body h5,
|
||||
.markdown-body h6 {
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
margin-bottom: 16px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.markdown-body h1 {
|
||||
font-size: 2em;
|
||||
}
|
||||
|
||||
.markdown-body h1,
|
||||
.markdown-body h2 {
|
||||
padding-bottom: 0.3em;
|
||||
}
|
||||
|
||||
.markdown-body h2 {
|
||||
border-bottom: 1px solid #eaecef;
|
||||
}
|
||||
|
||||
.markdown-body h2 {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.markdown-body h3 {
|
||||
font-size: 1.25em;
|
||||
}
|
||||
|
||||
.markdown-body h4 {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.markdown-body h5 {
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
.markdown-body h6 {
|
||||
color: #6a737d;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.markdown-body ol,
|
||||
.markdown-body ul {
|
||||
padding-left: 2em;
|
||||
}
|
||||
|
||||
.markdown-body ol ol,
|
||||
.markdown-body ol ul,
|
||||
.markdown-body ul ol,
|
||||
.markdown-body ul ul {
|
||||
margin-bottom: 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.markdown-body li {
|
||||
word-wrap: break-all;
|
||||
}
|
||||
|
||||
.markdown-body li > p {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.markdown-body li + li {
|
||||
margin-top: 0.25em;
|
||||
}
|
||||
|
||||
.markdown-body dl {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.markdown-body dl dt {
|
||||
font-size: 1em;
|
||||
font-style: italic;
|
||||
font-weight: 600;
|
||||
margin-top: 16px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.markdown-body dl dd {
|
||||
margin-bottom: 16px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.markdown-body table {
|
||||
display: block;
|
||||
overflow: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.markdown-body table th {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.markdown-body table td,
|
||||
.markdown-body table th {
|
||||
border: 1px solid #dfe2e5;
|
||||
padding: 6px 13px;
|
||||
}
|
||||
|
||||
.markdown-body table tr {
|
||||
background-color: #fff;
|
||||
border-top: 1px solid #c6cbd1;
|
||||
}
|
||||
|
||||
.markdown-body table tr:nth-child(2n) {
|
||||
background-color: #f6f8fa;
|
||||
}
|
||||
|
||||
.markdown-body img {
|
||||
background-color: #fff;
|
||||
box-sizing: content-box;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.markdown-body img[align='right'] {
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.markdown-body img[align='left'] {
|
||||
padding-right: 20px;
|
||||
}
|
||||
|
||||
.markdown-body code {
|
||||
background-color: rgba(27, 31, 35, 0.05);
|
||||
border-radius: 3px;
|
||||
font-size: 85%;
|
||||
margin: 0;
|
||||
padding: 0.2em 0.4em;
|
||||
}
|
||||
|
||||
.markdown-body pre {
|
||||
word-wrap: normal;
|
||||
}
|
||||
|
||||
.markdown-body pre > code {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
font-size: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
white-space: pre;
|
||||
word-break: normal;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.markdown-body .highlight {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.markdown-body .highlight pre {
|
||||
margin-bottom: 0;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
.markdown-body .highlight pre,
|
||||
.markdown-body pre {
|
||||
background-color: #f6f8fa;
|
||||
border-radius: 3px;
|
||||
font-size: 85%;
|
||||
line-height: 1.45;
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.markdown-body pre code {
|
||||
background-color: transparent;
|
||||
border: 0;
|
||||
display: inline;
|
||||
line-height: inherit;
|
||||
margin: 0;
|
||||
max-width: auto;
|
||||
overflow: visible;
|
||||
padding: 0;
|
||||
word-wrap: normal;
|
||||
}
|
||||
|
||||
.markdown-body .commit-tease-sha {
|
||||
color: #444d56;
|
||||
display: inline-block;
|
||||
font-family: SFMono-Regular, Consolas, Liberation Mono, Menlo, Courier,
|
||||
monospace;
|
||||
font-size: 90%;
|
||||
}
|
||||
|
||||
.markdown-body .blob-wrapper {
|
||||
border-bottom-left-radius: 3px;
|
||||
border-bottom-right-radius: 3px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.markdown-body .blob-wrapper-embedded {
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.markdown-body .blob-num {
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
color: rgba(27, 31, 35, 0.3);
|
||||
cursor: pointer;
|
||||
font-family: SFMono-Regular, Consolas, Liberation Mono, Menlo, Courier,
|
||||
monospace;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
min-width: 50px;
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
text-align: right;
|
||||
user-select: none;
|
||||
vertical-align: top;
|
||||
white-space: nowrap;
|
||||
width: 1%;
|
||||
}
|
||||
|
||||
.markdown-body .blob-num:hover {
|
||||
color: rgba(27, 31, 35, 0.6);
|
||||
}
|
||||
|
||||
.markdown-body .blob-num:before {
|
||||
content: attr(data-line-number);
|
||||
}
|
||||
|
||||
.markdown-body .blob-code {
|
||||
line-height: 20px;
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
position: relative;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.markdown-body .blob-code-inner {
|
||||
color: #24292e;
|
||||
font-family: SFMono-Regular, Consolas, Liberation Mono, Menlo, Courier,
|
||||
monospace;
|
||||
font-size: 12px;
|
||||
overflow: visible;
|
||||
white-space: pre;
|
||||
word-wrap: normal;
|
||||
}
|
||||
|
||||
.markdown-body .pl-token.active,
|
||||
.markdown-body .pl-token:hover {
|
||||
background: #ffea7f;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.markdown-body kbd {
|
||||
background-color: #fafbfc;
|
||||
border: 1px solid #d1d5da;
|
||||
border-bottom-color: #c6cbd1;
|
||||
border-radius: 3px;
|
||||
box-shadow: inset 0 -1px 0 #c6cbd1;
|
||||
color: #444d56;
|
||||
display: inline-block;
|
||||
font: 11px SFMono-Regular, Consolas, Liberation Mono, Menlo, Courier,
|
||||
monospace;
|
||||
line-height: 10px;
|
||||
padding: 3px 5px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.markdown-body :checked + .radio-label {
|
||||
border-color: #0366d6;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.markdown-body .tab-size[data-tab-size='1'] {
|
||||
-moz-tab-size: 1;
|
||||
tab-size: 1;
|
||||
}
|
||||
|
||||
.markdown-body .tab-size[data-tab-size='2'] {
|
||||
-moz-tab-size: 2;
|
||||
tab-size: 2;
|
||||
}
|
||||
|
||||
.markdown-body .tab-size[data-tab-size='3'] {
|
||||
-moz-tab-size: 3;
|
||||
tab-size: 3;
|
||||
}
|
||||
|
||||
.markdown-body .tab-size[data-tab-size='4'] {
|
||||
-moz-tab-size: 4;
|
||||
tab-size: 4;
|
||||
}
|
||||
|
||||
.markdown-body .tab-size[data-tab-size='5'] {
|
||||
-moz-tab-size: 5;
|
||||
tab-size: 5;
|
||||
}
|
||||
|
||||
.markdown-body .tab-size[data-tab-size='6'] {
|
||||
-moz-tab-size: 6;
|
||||
tab-size: 6;
|
||||
}
|
||||
|
||||
.markdown-body .tab-size[data-tab-size='7'] {
|
||||
-moz-tab-size: 7;
|
||||
tab-size: 7;
|
||||
}
|
||||
|
||||
.markdown-body .tab-size[data-tab-size='8'] {
|
||||
-moz-tab-size: 8;
|
||||
tab-size: 8;
|
||||
}
|
||||
|
||||
.markdown-body .tab-size[data-tab-size='9'] {
|
||||
-moz-tab-size: 9;
|
||||
tab-size: 9;
|
||||
}
|
||||
|
||||
.markdown-body .tab-size[data-tab-size='10'] {
|
||||
-moz-tab-size: 10;
|
||||
tab-size: 10;
|
||||
}
|
||||
|
||||
.markdown-body .tab-size[data-tab-size='11'] {
|
||||
-moz-tab-size: 11;
|
||||
tab-size: 11;
|
||||
}
|
||||
|
||||
.markdown-body .tab-size[data-tab-size='12'] {
|
||||
-moz-tab-size: 12;
|
||||
tab-size: 12;
|
||||
}
|
||||
|
||||
.markdown-body .task-list-item {
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
.markdown-body .task-list-item + .task-list-item {
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.markdown-body .task-list-item input {
|
||||
margin: 0 0.2em 0.25em -1.6em;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.markdown-body hr {
|
||||
border-bottom-color: #eee;
|
||||
}
|
||||
|
||||
.markdown-body .pl-0 {
|
||||
padding-left: 0 !important;
|
||||
}
|
||||
|
||||
.markdown-body .pl-1 {
|
||||
padding-left: 4px !important;
|
||||
}
|
||||
|
||||
.markdown-body .pl-2 {
|
||||
padding-left: 8px !important;
|
||||
}
|
||||
|
||||
.markdown-body .pl-3 {
|
||||
padding-left: 16px !important;
|
||||
}
|
||||
|
||||
.markdown-body .pl-4 {
|
||||
padding-left: 24px !important;
|
||||
}
|
||||
|
||||
.markdown-body .pl-5 {
|
||||
padding-left: 32px !important;
|
||||
}
|
||||
|
||||
.markdown-body .pl-6 {
|
||||
padding-left: 40px !important;
|
||||
}
|
||||
|
||||
.markdown-body .pl-7 {
|
||||
padding-left: 48px !important;
|
||||
}
|
||||
|
||||
.markdown-body .pl-8 {
|
||||
padding-left: 64px !important;
|
||||
}
|
||||
|
||||
.markdown-body .pl-9 {
|
||||
padding-left: 80px !important;
|
||||
}
|
||||
|
||||
.markdown-body .pl-10 {
|
||||
padding-left: 96px !important;
|
||||
}
|
||||
|
||||
.markdown-body .pl-11 {
|
||||
padding-left: 112px !important;
|
||||
}
|
||||
|
||||
.markdown-body .pl-12 {
|
||||
padding-left: 128px !important;
|
||||
}
|
||||
39
pkg/server/assets/styles/src/_marker.scss
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
/* Copyright (C) 2019, 2020 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
.marker {
|
||||
display: inline-block;
|
||||
padding: 0.25em 0.4em;
|
||||
font-size: 75%;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
vertical-align: baseline;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.marker-first {
|
||||
color: #fff;
|
||||
background-color: #007bff;
|
||||
}
|
||||
|
||||
.marker-info {
|
||||
color: #fff;
|
||||
background-color: #17a2b8;
|
||||
}
|
||||
102
pkg/server/assets/styles/src/_note.scss
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
.note-page {
|
||||
// min-height: calc(100vh - 57px);
|
||||
background: $lighter-gray;
|
||||
flex-grow: 1;
|
||||
flex-basis: 0;
|
||||
|
||||
// .inner {
|
||||
// display: flex;
|
||||
// justify-content: center;
|
||||
// padding-top: rem(40px);
|
||||
// padding-bottom: rem(40px);
|
||||
//
|
||||
// @include breakpoint(md) {
|
||||
// padding-top: rem(52px);
|
||||
// padding-bottom: rem(52px);
|
||||
// }
|
||||
// }
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: rem(12px) rem(16px);
|
||||
border-bottom: 1px solid $border-color;
|
||||
}
|
||||
.header-left,
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.book-icon {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
padding: rem(12px) rem(16px);
|
||||
}
|
||||
|
||||
.collapsed-content {
|
||||
color: $light-gray;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
@include font-size('small');
|
||||
padding: rem(12px) rem(16px);
|
||||
}
|
||||
|
||||
.ts {
|
||||
color: $light-gray;
|
||||
}
|
||||
.ts-lead {
|
||||
display: none;
|
||||
@include breakpoint(md) {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
.match {
|
||||
display: inline-block;
|
||||
background: #f7f77d;
|
||||
}
|
||||
|
||||
.book-label {
|
||||
@include font-size('medium');
|
||||
font-weight: 600;
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: $black;
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
|
||||
&:hover {
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// header
|
||||
.header {
|
||||
.book-label {
|
||||
max-width: rem(200px);
|
||||
margin-left: rem(12px);
|
||||
|
||||
@include breakpoint(sm) {
|
||||
max-width: rem(200px);
|
||||
}
|
||||
@include breakpoint(md) {
|
||||
max-width: rem(420px);
|
||||
}
|
||||
@include breakpoint(lg) {
|
||||
max-width: rem(600px);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
367
pkg/server/assets/styles/src/_reboot.scss
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
/* Copyright (C) 2019, 2020 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/*!
|
||||
* Bootstrap Reboot v4.3.1 (https://getbootstrap.com/)
|
||||
* Copyright 2011-2019 The Bootstrap Authors
|
||||
* Copyright 2011-2019 Twitter, Inc.
|
||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
|
||||
* Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md)
|
||||
*/
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: sans-serif;
|
||||
line-height: 1.15;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
article,
|
||||
aside,
|
||||
figcaption,
|
||||
figure,
|
||||
footer,
|
||||
header,
|
||||
hgroup,
|
||||
main,
|
||||
nav,
|
||||
section {
|
||||
display: block;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
||||
'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji',
|
||||
'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
color: #212529;
|
||||
text-align: left;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
[tabindex='-1']:focus {
|
||||
outline: 0 !important;
|
||||
}
|
||||
|
||||
hr {
|
||||
box-sizing: content-box;
|
||||
height: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
abbr[title],
|
||||
abbr[data-original-title] {
|
||||
text-decoration: underline;
|
||||
-webkit-text-decoration: underline dotted;
|
||||
text-decoration: underline dotted;
|
||||
cursor: help;
|
||||
border-bottom: 0;
|
||||
-webkit-text-decoration-skip-ink: none;
|
||||
text-decoration-skip-ink: none;
|
||||
}
|
||||
|
||||
address {
|
||||
margin-bottom: 1rem;
|
||||
font-style: normal;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
ol,
|
||||
ul,
|
||||
dl {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
ol ol,
|
||||
ul ul,
|
||||
ol ul,
|
||||
ul ol {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
dt {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin-bottom: 0.5rem;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
b,
|
||||
strong {
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
small {
|
||||
font-size: 80%;
|
||||
}
|
||||
|
||||
sub,
|
||||
sup {
|
||||
position: relative;
|
||||
font-size: 75%;
|
||||
line-height: 0;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
sub {
|
||||
bottom: -0.25em;
|
||||
}
|
||||
|
||||
sup {
|
||||
top: -0.5em;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #007bff;
|
||||
text-decoration: none;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #0056b3;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
a:not([href]):not([tabindex]) {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:not([href]):not([tabindex]):hover,
|
||||
a:not([href]):not([tabindex]):focus {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:not([href]):not([tabindex]):focus {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
pre,
|
||||
code,
|
||||
kbd,
|
||||
samp {
|
||||
font-family: SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
|
||||
'Courier New', monospace;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
figure {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
img {
|
||||
vertical-align: middle;
|
||||
border-style: none;
|
||||
}
|
||||
|
||||
svg {
|
||||
overflow: hidden;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
caption {
|
||||
padding-top: 0.75rem;
|
||||
padding-bottom: 0.75rem;
|
||||
color: #6c757d;
|
||||
text-align: left;
|
||||
caption-side: bottom;
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: inherit;
|
||||
}
|
||||
|
||||
label {
|
||||
display: inline-block;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
button:focus {
|
||||
outline: 1px dotted;
|
||||
outline: 5px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
input,
|
||||
button,
|
||||
select,
|
||||
optgroup,
|
||||
textarea {
|
||||
margin: 0;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
button,
|
||||
select {
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
select {
|
||||
word-wrap: normal;
|
||||
}
|
||||
|
||||
button,
|
||||
[type='button'],
|
||||
[type='reset'],
|
||||
[type='submit'] {
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
|
||||
button:not(:disabled),
|
||||
[type='button']:not(:disabled),
|
||||
[type='reset']:not(:disabled),
|
||||
[type='submit']:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button::-moz-focus-inner,
|
||||
[type='button']::-moz-focus-inner,
|
||||
[type='reset']::-moz-focus-inner,
|
||||
[type='submit']::-moz-focus-inner {
|
||||
padding: 0;
|
||||
border-style: none;
|
||||
}
|
||||
|
||||
input[type='radio'],
|
||||
input[type='checkbox'] {
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
input[type='date'],
|
||||
input[type='time'],
|
||||
input[type='datetime-local'],
|
||||
input[type='month'] {
|
||||
-webkit-appearance: listbox;
|
||||
}
|
||||
|
||||
textarea {
|
||||
overflow: auto;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
legend {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
padding: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 1.5rem;
|
||||
line-height: inherit;
|
||||
color: inherit;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
progress {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
[type='number']::-webkit-inner-spin-button,
|
||||
[type='number']::-webkit-outer-spin-button {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
[type='search'] {
|
||||
outline-offset: -2px;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
[type='search']::-webkit-search-decoration {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
::-webkit-file-upload-button {
|
||||
font: inherit;
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
|
||||
output {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
summary {
|
||||
display: list-item;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
template {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
/*# sourceMappingURL=bootstrap-reboot.css.map */
|
||||
116
pkg/server/assets/styles/src/_rem.scss
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
/* Copyright (C) 2019, 2020 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/*
|
||||
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...);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
62
pkg/server/assets/styles/src/_responsive.scss
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
/* Copyright (C) 2019, 2020 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@import './variables';
|
||||
|
||||
@mixin breakpoint($point) {
|
||||
@if $point == xl {
|
||||
@media (min-width: $xl-breakpoint) {
|
||||
@content;
|
||||
}
|
||||
} @else if $point == lg {
|
||||
@media (min-width: $lg-breakpoint) {
|
||||
@content;
|
||||
}
|
||||
} @else if $point == md {
|
||||
@media (min-width: $md-breakpoint) {
|
||||
@content;
|
||||
}
|
||||
} @else if $point == sm {
|
||||
@media (min-width: $sm-breakpoint) {
|
||||
@content;
|
||||
}
|
||||
} @else if $point == smonly {
|
||||
@media (min-width: $sm-breakpoint) and (max-width: $md-breakpoint - 1px) {
|
||||
@content;
|
||||
}
|
||||
} @else if $point == smdown {
|
||||
@media (max-width: $md-breakpoint - 1px) {
|
||||
@content;
|
||||
}
|
||||
} @else if $point == mdonly {
|
||||
@media (min-width: $md-breakpoint) and (max-width: $lg-breakpoint - 1px) {
|
||||
@content;
|
||||
}
|
||||
} @else if $point == mddown {
|
||||
@media (max-width: $lg-breakpoint - 1px) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// landscape is the mobile landscape mode
|
||||
@mixin landscape() {
|
||||
@media (max-height: 400px) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
463
pkg/server/assets/styles/src/_select.scss
Normal file
|
|
@ -0,0 +1,463 @@
|
|||
/* Copyright (C) 2019, 2020 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* React Select
|
||||
* ============
|
||||
* Created by Jed Watson and Joss Mackison for KeystoneJS, http://www.keystonejs.com/
|
||||
* https://twitter.com/jedwatson https://twitter.com/jossmackison https://twitter.com/keystonejs
|
||||
* MIT License: https://github.com/JedWatson/react-select
|
||||
*/
|
||||
.Select {
|
||||
position: relative;
|
||||
}
|
||||
.Select input::-webkit-contacts-auto-fill-button,
|
||||
.Select input::-webkit-credentials-auto-fill-button {
|
||||
display: none !important;
|
||||
}
|
||||
.Select input::-ms-clear {
|
||||
display: none !important;
|
||||
}
|
||||
.Select input::-ms-reveal {
|
||||
display: none !important;
|
||||
}
|
||||
.Select,
|
||||
.Select div,
|
||||
.Select input,
|
||||
.Select span {
|
||||
-webkit-box-sizing: border-box;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.Select.is-disabled .Select-arrow-zone {
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
opacity: 0.35;
|
||||
}
|
||||
.Select.is-disabled > .Select-control {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
.Select.is-disabled > .Select-control:hover {
|
||||
box-shadow: none;
|
||||
}
|
||||
.Select.is-open > .Select-control {
|
||||
border-bottom-right-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
background: #fff;
|
||||
}
|
||||
.Select.is-open > .Select-control .Select-arrow {
|
||||
top: -2px;
|
||||
border-color: transparent transparent #999;
|
||||
border-width: 0 5px 5px;
|
||||
}
|
||||
.Select.is-searchable.is-open > .Select-control {
|
||||
cursor: text;
|
||||
}
|
||||
.Select.is-searchable.is-focused:not(.is-open) > .Select-control {
|
||||
cursor: text;
|
||||
}
|
||||
.Select.is-focused > .Select-control {
|
||||
background: #fff;
|
||||
}
|
||||
.Select.is-focused > .Select-control {
|
||||
border-top-color: #b6c9e9;
|
||||
border-bottom-color: #b6c9e9;
|
||||
}
|
||||
.Select.is-focused:not(.is-open) > .Select-control {
|
||||
background: #fff;
|
||||
}
|
||||
.Select.has-value.is-clearable.Select--single > .Select-control .Select-value {
|
||||
padding-right: 42px;
|
||||
}
|
||||
.Select.has-value.Select--single > .Select-control .Select-value .Select-value-label,
|
||||
.Select.has-value.is-pseudo-focused.Select--single
|
||||
> .Select-control
|
||||
.Select-value
|
||||
.Select-value-label {
|
||||
color: #333;
|
||||
}
|
||||
.Select.has-value.Select--single > .Select-control .Select-value a.Select-value-label,
|
||||
.Select.has-value.is-pseudo-focused.Select--single
|
||||
> .Select-control
|
||||
.Select-value
|
||||
a.Select-value-label {
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
.Select.has-value.Select--single > .Select-control .Select-value a.Select-value-label:hover,
|
||||
.Select.has-value.is-pseudo-focused.Select--single
|
||||
> .Select-control
|
||||
.Select-value
|
||||
a.Select-value-label:hover,
|
||||
.Select.has-value.Select--single > .Select-control .Select-value a.Select-value-label:focus,
|
||||
.Select.has-value.is-pseudo-focused.Select--single
|
||||
> .Select-control
|
||||
.Select-value
|
||||
a.Select-value-label:focus {
|
||||
color: #007eff;
|
||||
outline: none;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.Select.has-value.Select--single > .Select-control .Select-value a.Select-value-label:focus,
|
||||
.Select.has-value.is-pseudo-focused.Select--single
|
||||
> .Select-control
|
||||
.Select-value
|
||||
a.Select-value-label:focus {
|
||||
background: #fff;
|
||||
}
|
||||
.Select.has-value.is-pseudo-focused .Select-input {
|
||||
opacity: 0;
|
||||
}
|
||||
.Select.is-open .Select-arrow,
|
||||
.Select .Select-arrow-zone:hover > .Select-arrow {
|
||||
border-top-color: #666;
|
||||
}
|
||||
.Select.Select--rtl {
|
||||
direction: rtl;
|
||||
text-align: right;
|
||||
}
|
||||
.Select-control {
|
||||
background-color: #fff;
|
||||
color: #333;
|
||||
cursor: default;
|
||||
display: table;
|
||||
border-top: 1px solid #e2e2e2;
|
||||
border-bottom: 1px solid #e2e2e2;
|
||||
height: 36px;
|
||||
outline: none;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
.Select-control:hover {
|
||||
// box-shadow: inset 0px 0px 3px 2px rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
.Select-control .Select-input:focus {
|
||||
outline: none;
|
||||
background: #fff;
|
||||
}
|
||||
.Select-placeholder,
|
||||
.Select--single > .Select-control .Select-value {
|
||||
bottom: 0;
|
||||
font-weight: 300;
|
||||
color: #c3c3c3;
|
||||
left: 0;
|
||||
line-height: 34px;
|
||||
// padding-left: 10px;
|
||||
padding-left: 16px;
|
||||
padding-right: 10px;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.Select-input {
|
||||
height: 34px;
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.Select-input > input {
|
||||
width: 100%;
|
||||
background: none transparent;
|
||||
border: 0 none;
|
||||
box-shadow: none;
|
||||
cursor: default;
|
||||
display: inline-block;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
margin: 0;
|
||||
outline: none;
|
||||
line-height: 17px;
|
||||
/* For IE 8 compatibility */
|
||||
padding: 8px 0 12px;
|
||||
/* For IE 8 compatibility */
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
.is-focused .Select-input > input {
|
||||
cursor: text;
|
||||
}
|
||||
.has-value.is-pseudo-focused .Select-input {
|
||||
opacity: 0;
|
||||
}
|
||||
.Select-control:not(.is-searchable) > .Select-input {
|
||||
outline: none;
|
||||
}
|
||||
.Select-loading-zone {
|
||||
cursor: pointer;
|
||||
display: table-cell;
|
||||
position: relative;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
width: 16px;
|
||||
}
|
||||
.Select-loading {
|
||||
-webkit-animation: Select-animation-spin 400ms infinite linear;
|
||||
-o-animation: Select-animation-spin 400ms infinite linear;
|
||||
animation: Select-animation-spin 400ms infinite linear;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #ccc;
|
||||
border-right-color: #333;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.Select-clear-zone {
|
||||
-webkit-animation: Select-animation-fadeIn 200ms;
|
||||
-o-animation: Select-animation-fadeIn 200ms;
|
||||
animation: Select-animation-fadeIn 200ms;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
display: table-cell;
|
||||
position: relative;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
width: 17px;
|
||||
}
|
||||
.Select-clear-zone:hover {
|
||||
color: #d0021b;
|
||||
}
|
||||
.Select-clear {
|
||||
display: inline-block;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
.Select--multi .Select-clear-zone {
|
||||
width: 17px;
|
||||
}
|
||||
.Select-arrow-zone {
|
||||
cursor: pointer;
|
||||
display: table-cell;
|
||||
position: relative;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
width: 25px;
|
||||
padding-right: 5px;
|
||||
}
|
||||
.Select--rtl .Select-arrow-zone {
|
||||
padding-right: 0;
|
||||
padding-left: 5px;
|
||||
}
|
||||
.Select-arrow {
|
||||
border-color: #999 transparent transparent;
|
||||
border-style: solid;
|
||||
border-width: 5px 5px 2.5px;
|
||||
display: inline-block;
|
||||
height: 0;
|
||||
width: 0;
|
||||
position: relative;
|
||||
}
|
||||
.Select-control > *:last-child {
|
||||
padding-right: 5px;
|
||||
}
|
||||
.Select--multi .Select-multi-value-wrapper {
|
||||
display: inline-block;
|
||||
}
|
||||
.Select .Select-aria-only {
|
||||
position: absolute;
|
||||
display: inline-block;
|
||||
height: 1px;
|
||||
width: 1px;
|
||||
margin: -1px;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
overflow: hidden;
|
||||
float: left;
|
||||
}
|
||||
@-webkit-keyframes Select-animation-fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes Select-animation-fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.Select-menu-outer {
|
||||
border-bottom-right-radius: 4px;
|
||||
border-bottom-left-radius: 4px;
|
||||
background-color: #fff;
|
||||
border: 1px solid #ccc;
|
||||
border-top-color: #e6e6e6;
|
||||
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.06);
|
||||
box-sizing: border-box;
|
||||
margin-top: -1px;
|
||||
max-height: 200px;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 100%;
|
||||
width: 100%;
|
||||
z-index: 1;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.Select.is-open > .Select-menu-outer {
|
||||
border-top-color: #b6c9e9;
|
||||
}
|
||||
.Select-menu {
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.Select-option {
|
||||
box-sizing: border-box;
|
||||
background-color: #fff;
|
||||
color: #666666;
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
.Select-option:last-child {
|
||||
border-bottom-right-radius: 4px;
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
.Select-option.is-selected {
|
||||
background-color: #f5faff;
|
||||
/* Fallback color for IE 8 */
|
||||
background-color: rgba(0, 126, 255, 0.04);
|
||||
color: #333;
|
||||
}
|
||||
.Select-option.is-focused {
|
||||
background-color: #ebf5ff;
|
||||
/* Fallback color for IE 8 */
|
||||
background-color: rgba(0, 126, 255, 0.08);
|
||||
color: #333;
|
||||
}
|
||||
.Select-option.is-disabled {
|
||||
color: #cccccc;
|
||||
cursor: default;
|
||||
}
|
||||
.Select-noresults {
|
||||
box-sizing: border-box;
|
||||
color: #999999;
|
||||
cursor: default;
|
||||
display: block;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
.Select--multi .Select-input {
|
||||
vertical-align: middle;
|
||||
margin-left: 10px;
|
||||
padding: 0;
|
||||
}
|
||||
.Select--multi.Select--rtl .Select-input {
|
||||
margin-left: 0;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.Select--multi.has-value .Select-input {
|
||||
margin-left: 5px;
|
||||
}
|
||||
.Select--multi .Select-value {
|
||||
background-color: #ebf5ff;
|
||||
/* Fallback color for IE 8 */
|
||||
background-color: rgba(0, 126, 255, 0.08);
|
||||
border-radius: 2px;
|
||||
border: 1px solid #c2e0ff;
|
||||
/* Fallback color for IE 8 */
|
||||
border: 1px solid rgba(0, 126, 255, 0.24);
|
||||
color: #007eff;
|
||||
display: inline-block;
|
||||
font-size: 0.9em;
|
||||
line-height: 1.4;
|
||||
margin-left: 5px;
|
||||
margin-top: 5px;
|
||||
vertical-align: top;
|
||||
}
|
||||
.Select--multi .Select-value-icon,
|
||||
.Select--multi .Select-value-label {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.Select--multi .Select-value-label {
|
||||
border-bottom-right-radius: 2px;
|
||||
border-top-right-radius: 2px;
|
||||
cursor: default;
|
||||
padding: 2px 5px;
|
||||
}
|
||||
.Select--multi a.Select-value-label {
|
||||
color: #007eff;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
.Select--multi a.Select-value-label:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.Select--multi .Select-value-icon {
|
||||
cursor: pointer;
|
||||
border-bottom-left-radius: 2px;
|
||||
border-top-left-radius: 2px;
|
||||
border-right: 1px solid #c2e0ff;
|
||||
/* Fallback color for IE 8 */
|
||||
border-right: 1px solid rgba(0, 126, 255, 0.24);
|
||||
padding: 1px 5px 3px;
|
||||
}
|
||||
.Select--multi .Select-value-icon:hover,
|
||||
.Select--multi .Select-value-icon:focus {
|
||||
background-color: #d8eafd;
|
||||
/* Fallback color for IE 8 */
|
||||
background-color: rgba(0, 113, 230, 0.08);
|
||||
color: #0071e6;
|
||||
}
|
||||
.Select--multi .Select-value-icon:active {
|
||||
background-color: #c2e0ff;
|
||||
/* Fallback color for IE 8 */
|
||||
background-color: rgba(0, 126, 255, 0.24);
|
||||
}
|
||||
.Select--multi.Select--rtl .Select-value {
|
||||
margin-left: 0;
|
||||
margin-right: 5px;
|
||||
}
|
||||
.Select--multi.Select--rtl .Select-value-icon {
|
||||
border-right: none;
|
||||
border-left: 1px solid #c2e0ff;
|
||||
/* Fallback color for IE 8 */
|
||||
border-left: 1px solid rgba(0, 126, 255, 0.24);
|
||||
}
|
||||
.Select--multi.is-disabled .Select-value {
|
||||
background-color: #fcfcfc;
|
||||
border: 1px solid #e3e3e3;
|
||||
color: #333;
|
||||
}
|
||||
.Select--multi.is-disabled .Select-value-icon {
|
||||
cursor: not-allowed;
|
||||
border-right: 1px solid #e3e3e3;
|
||||
}
|
||||
.Select--multi.is-disabled .Select-value-icon:hover,
|
||||
.Select--multi.is-disabled .Select-value-icon:focus,
|
||||
.Select--multi.is-disabled .Select-value-icon:active {
|
||||
background-color: #fcfcfc;
|
||||
}
|
||||
@keyframes Select-animation-spin {
|
||||
to {
|
||||
transform: rotate(1turn);
|
||||
}
|
||||
}
|
||||
@-webkit-keyframes Select-animation-spin {
|
||||
to {
|
||||
-webkit-transform: rotate(1turn);
|
||||
}
|
||||
}
|
||||
147
pkg/server/assets/styles/src/_settings.scss
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
@import './theme';
|
||||
@import './font';
|
||||
|
||||
.settings-page {
|
||||
.sidebar {
|
||||
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.2);
|
||||
background: white;
|
||||
margin-bottom: rem(20px);
|
||||
margin-top: rem(20px);
|
||||
|
||||
@include breakpoint(lg) {
|
||||
margin-bottom: 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-item {
|
||||
display: block;
|
||||
padding: rem(12px) rem(16px);
|
||||
border-left: 4px solid transparent;
|
||||
@include font-size('regular');
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
background: $light;
|
||||
}
|
||||
|
||||
&.active {
|
||||
font-weight: 600;
|
||||
border-left-color: $first;
|
||||
}
|
||||
}
|
||||
|
||||
.setting-section-wrapper {
|
||||
.header {
|
||||
@include breakpoint(lg) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.setting-section {
|
||||
margin-top: rem(24px);
|
||||
background: white;
|
||||
box-shadow: 0 0 8px rgba(0, 0, 0, 0.14);
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
@include font-size('regular');
|
||||
font-weight: 600;
|
||||
padding-bottom: rem(4px);
|
||||
background: $light;
|
||||
padding: rem(16px) rem(20px);
|
||||
}
|
||||
.section-content {
|
||||
margin-top: rem(20px);
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: rem(18px);
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.setting-row {
|
||||
padding: rem(16px) rem(20px);
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid $border-color;
|
||||
}
|
||||
|
||||
.setting-row-summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
// align-items: flex-start;
|
||||
|
||||
@include breakpoint(md) {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.setting-row-main {
|
||||
padding-top: rem(24px);
|
||||
}
|
||||
|
||||
.setting-name {
|
||||
font-weight: 400;
|
||||
@include font-size('regular');
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.setting-desc {
|
||||
margin-bottom: 0;
|
||||
@include font-size('small');
|
||||
color: $gray;
|
||||
}
|
||||
.setting-action {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@include breakpoint(md) {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
.setting-right {
|
||||
display: flex;
|
||||
word-break: break-all;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: rem(4px);
|
||||
|
||||
@include breakpoint(md) {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.setting-edit {
|
||||
color: $link;
|
||||
padding: 0;
|
||||
|
||||
&:hover {
|
||||
color: $link-hover;
|
||||
}
|
||||
@include breakpoint(md) {
|
||||
margin-left: rem(16px);
|
||||
}
|
||||
}
|
||||
|
||||
.input-row {
|
||||
& ~ .input-row,
|
||||
.input-row {
|
||||
margin-top: rem(12px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.email-verification-form {
|
||||
margin-left: rem(12px);
|
||||
}
|
||||
}
|
||||
241
pkg/server/assets/styles/src/_shared.scss
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
/* Copyright (C) 2019, 2020 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@import './font';
|
||||
@import './responsive';
|
||||
|
||||
@keyframes holderPulse {
|
||||
0% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
// placeholder frames
|
||||
.holder {
|
||||
animation: holderPulse 800ms infinite;
|
||||
background: #f4f4f4;
|
||||
|
||||
&.holder-dark {
|
||||
background: #e6e6e6;
|
||||
}
|
||||
}
|
||||
|
||||
input[type='text']:disabled,
|
||||
input[type='email']:disabled,
|
||||
input[type='number']:disabled,
|
||||
input[type='password']:disabled,
|
||||
textarea:disabled {
|
||||
background-color: $lighter-gray;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.list-unstyled {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.scrollbar-measure {
|
||||
position: absolute;
|
||||
top: -9999px;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
overflow: scroll;
|
||||
}
|
||||
|
||||
button {
|
||||
img,
|
||||
svg {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.text-input {
|
||||
border: 1px solid $border-color;
|
||||
padding: rem(8px) rem(12px);
|
||||
position: relative;
|
||||
border-radius: rem(4px);
|
||||
display: block;
|
||||
|
||||
&::placeholder {
|
||||
color: $gray;
|
||||
}
|
||||
&:focus {
|
||||
border-color: $light-blue;
|
||||
box-shadow: inset 0 1px 2px rgba(24, 31, 35, 0.075),
|
||||
0 0 0 0.2em rgba(4, 100, 210, 0.3);
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.text-input-small {
|
||||
padding: rem(4px) rem(12px);
|
||||
}
|
||||
|
||||
.text-input-medium {
|
||||
padding: rem(8px) rem(12px);
|
||||
}
|
||||
|
||||
.text-input-stretch {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.label-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
a {
|
||||
color: $link;
|
||||
|
||||
&:hover {
|
||||
color: $link-hover;
|
||||
}
|
||||
}
|
||||
|
||||
// normalize
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
// grid
|
||||
.container.mobile-fw {
|
||||
@include breakpoint(mddown) {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
.container.mobile-nopadding {
|
||||
@include breakpoint(mddown) {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
|
||||
.row {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
[class*='col-'] {
|
||||
// Apply to all column(s) inside the row
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
html body {
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.page {
|
||||
padding-top: rem(20px);
|
||||
padding-bottom: rem(20px);
|
||||
|
||||
&.page-mobile-full {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
|
||||
@include breakpoint(lg) {
|
||||
padding-top: rem(32px);
|
||||
padding-bottom: rem(32px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-top: rem(20px);
|
||||
|
||||
&.page-header-full {
|
||||
margin-bottom: rem(20px);
|
||||
}
|
||||
|
||||
@include breakpoint(lg) {
|
||||
// padding: 0;
|
||||
margin-bottom: rem(20px);
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.form-select {
|
||||
appearance: none;
|
||||
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAUCAYAAACEYr13AAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAACeSURBVHgBzZPBCYQwFERn2Qa2BEuwhJSyHawdrB1oB1qCV6uwhHj0qBXoBPwQRONXEXzwLoEXcCTAzfxogpPEdJyNcZCIWu8CO5+p8WOxoR9NnK3EYrEX/wOxuDmqUcSikejlXfCFfqie5ngE/ie4cVS/ibS0XB4anBhxSaKIU+xQBmLV8m6HZiW2OECEi4/JoXrO78AFHR1oTSvcxQTq7lVcue6CCAAAAABJRU5ErkJggg==');
|
||||
background-color: #fff;
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 8px center;
|
||||
background-size: 8px 10px;
|
||||
border: 1px solid $border-color;
|
||||
min-height: 34px;
|
||||
padding: 6px 8px;
|
||||
padding-right: 24px;
|
||||
outline: none;
|
||||
vertical-align: middle;
|
||||
border-radius: 4px;
|
||||
box-shadow: inset 0 1px 2px rgba(32, 36, 41, 0.08);
|
||||
|
||||
&:focus {
|
||||
border-color: #2188ff;
|
||||
outline: none;
|
||||
box-shadow: inset 0 1px 2px rgba(32, 36, 41, 0.08),
|
||||
0 0 0 2px rgba(3, 102, 214, 0.3);
|
||||
}
|
||||
&:disabled,
|
||||
&.form-select-disabled {
|
||||
background-image: url('data:image/png;base64, iVBORw0KGgoAAAANSUhEUgAAABAAAAAUCAYAAACEYr13AAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAEKSURBVHgBzVTNDYIwFC4NB46OwAi4gY7gETgoE6gTGCcwTgAJ4efGCLCBjMAIXrmA3yOhQazQhJj4JQ0v7fte3/e1hbFfIk3TYxzHp6kc7dtCFEUW5/xBcdM0a9d1S1kel00mSWKCnIkkxDSnXADIMYYEU9O0zPf91WwB6L6NyB3atrUMw7hNFkCbFyROmXYYmypMDMNwo+t6ztSwtW27oEAXrXBuwu2rCht+WPgU7C8gPCBzYOBKhQS5FTwIKBYeQFeJoWyiKNYH5Co6OCuQr/0JdBuPVyElQCd7GRMb3B3HebsHHzexrmvyQvZwqjFZWsDzvCc62BFhSGYD3UMsfs6ToKOd+6EsxgtrtWLW4gUN3AAAAABJRU5ErkJggg==');
|
||||
background-color: $lighter-gray;
|
||||
}
|
||||
}
|
||||
|
||||
.input-label {
|
||||
// width: 100%;
|
||||
width: auto;
|
||||
font-weight: 600;
|
||||
margin-bottom: rem(4px);
|
||||
@include font-size('small');
|
||||
}
|
||||
|
||||
.page-heading {
|
||||
@include font-size('x-large');
|
||||
}
|
||||
|
||||
.dropdown-caret {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
border-top-width: 4px;
|
||||
border-top-style: solid;
|
||||
border-right: 4px solid transparent;
|
||||
border-bottom: 0 solid transparent;
|
||||
border-left: 4px solid transparent;
|
||||
margin-left: rem(8px);
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
border-top: 1px solid #e9ecef;
|
||||
}
|
||||
47
pkg/server/assets/styles/src/_theme.scss
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
/* Copyright (C) 2019, 2020 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
// basic colors
|
||||
$black: #2a2a2a;
|
||||
$white: #ffffff;
|
||||
$light: #f7f9fa;
|
||||
$gray: #686868;
|
||||
$light-gray: #8c8c8c;
|
||||
$lighter-gray: #f3f3f3;
|
||||
$dark-gray: #637283;
|
||||
|
||||
// primary colors
|
||||
$first: #072a40;
|
||||
$second: #e7e7e7;
|
||||
$third: #0a4b73;
|
||||
|
||||
// functional colors
|
||||
$border-color: #d8d8d8;
|
||||
$border-color-light: $lighter-gray;
|
||||
|
||||
$link: #6f53c0;
|
||||
$link-hover: darken($link, 5%);
|
||||
|
||||
$danger-text: #cb2431;
|
||||
$danger-background: #f8d7da;
|
||||
|
||||
$blue: #0668d7;
|
||||
$light-blue: #ecf4ff;
|
||||
$green: #28a755;
|
||||
|
||||
$active: #49abfd;
|
||||
|
|
@ -16,13 +16,16 @@
|
|||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package api
|
||||
$header-height: 60px;
|
||||
$footer-height: 56px;
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
// breakpoints
|
||||
$xl-breakpoint: 1441px;
|
||||
$lg-breakpoint: 992px;
|
||||
$md-breakpoint: 576px;
|
||||
$sm-breakpoint: 321px;
|
||||
|
||||
func (a *API) checkHealth(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("ok"))
|
||||
:export {
|
||||
mdBreakpoint: $md-breakpoint;
|
||||
smBreakpoint: $sm-breakpoint;
|
||||
}
|
||||
144
pkg/server/assets/styles/src/main.scss
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
/* Copyright (C) 2019, 2020 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@import './reboot';
|
||||
@import './grid';
|
||||
@import './bootstrap';
|
||||
@import './buttons';
|
||||
@import './responsive';
|
||||
@import './select';
|
||||
@import './shared';
|
||||
@import './marker';
|
||||
@import './rem';
|
||||
@import './markdown';
|
||||
@import './hljs';
|
||||
|
||||
@import './login';
|
||||
@import './home';
|
||||
@import './note';
|
||||
@import './books';
|
||||
@import './settings';
|
||||
@import './header';
|
||||
@import './global';
|
||||
|
||||
html {
|
||||
font-size: 62.5%; /* 1.0 rem = 10px */
|
||||
}
|
||||
|
||||
html body {
|
||||
margin: 0;
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
// input[type='email'],
|
||||
// input[type='password'],
|
||||
// input[type='text'] {
|
||||
// &::placeholder {
|
||||
// color: #aaa;
|
||||
// }
|
||||
// }
|
||||
|
||||
.main-content {
|
||||
padding-top: 24px;
|
||||
}
|
||||
|
||||
.no-scroll {
|
||||
overflow: hidden;
|
||||
|
||||
// prevent ios safari from scrolling
|
||||
// but it causes page to scroll to top on modal open
|
||||
// position: fixed;
|
||||
// left: 0;
|
||||
// right: 0;
|
||||
// top: 0;
|
||||
// bottom: 0;
|
||||
}
|
||||
|
||||
.container.mobile-nopadding {
|
||||
@include breakpoint(mdonly) {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
@include breakpoint(mddown) {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
|
||||
.row {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
[class*='col-'] {
|
||||
// Apply to all column(s) inside the row
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// START: override bootstrap
|
||||
.form-control {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
.dropdown {
|
||||
position: inherit;
|
||||
}
|
||||
// END: override bootstrap
|
||||
// START: bootstrap related
|
||||
.input-group {
|
||||
input ~ button {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
}
|
||||
// END: bootstrap related
|
||||
|
||||
// .twitter-follow-btn {
|
||||
// position: fixed;
|
||||
// bottom: 0px;
|
||||
// right: 10px;
|
||||
// }
|
||||
|
||||
.page {
|
||||
//padding: 35px 0;
|
||||
//min-height: calc(100vh - 57px);
|
||||
//min-height: 100vh;
|
||||
// padding-top: rem(48px);
|
||||
}
|
||||
.page-bgdark {
|
||||
background: #ececec;
|
||||
}
|
||||
|
||||
// Measure scrollbar width for padding body during modal show/hide
|
||||
.modal-scrollbar-measure {
|
||||
position: absolute;
|
||||
top: -9999px;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
overflow: scroll;
|
||||
}
|
||||
|
||||
.input {
|
||||
border-radius: rem(4px);
|
||||
background-clip: padding-box;
|
||||
border: 1px solid #ced4da;
|
||||
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
|
||||
}
|
||||
15
pkg/server/buildinfo/info.go
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
package buildinfo
|
||||
|
||||
var (
|
||||
// Version is the server version
|
||||
Version = "master"
|
||||
// CSSFiles is the css files
|
||||
CSSFiles = ""
|
||||
// JSFiles is the js files
|
||||
JSFiles = ""
|
||||
// RootURL is the root url
|
||||
RootURL = "/"
|
||||
// Standalone reprsents whether the build is for on-premises. It is a string
|
||||
// rather than a boolean, so that it can be overridden during compile time.
|
||||
Standalone = "false"
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
10
pkg/server/consts/consts.go
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
package consts
|
||||
|
||||
const (
|
||||
// ContentTypeForm is the content type header for form encoded data
|
||||
ContentTypeForm = "application/x-www-form-urlencoded"
|
||||
// ContentTypeForm is the content type header for JSON encoded data
|
||||
ContentTypeJSON = "application/json"
|
||||
// ContentTypeHTML is the content type header for HTML
|
||||
ContentTypeHTML = "text/html"
|
||||
)
|
||||
64
pkg/server/context/user.go
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
package context
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/dnote/dnote/pkg/server/database"
|
||||
)
|
||||
|
||||
const (
|
||||
userKey privateKey = "user"
|
||||
accountKey privateKey = "account"
|
||||
tokenKey privateKey = "token"
|
||||
)
|
||||
|
||||
type privateKey string
|
||||
|
||||
// WithUser creates a new context with the given user
|
||||
func WithUser(ctx context.Context, user *database.User) context.Context {
|
||||
return context.WithValue(ctx, userKey, user)
|
||||
}
|
||||
|
||||
// WithAccount creates a new context with the given account
|
||||
func WithAccount(ctx context.Context, account *database.Account) context.Context {
|
||||
return context.WithValue(ctx, accountKey, account)
|
||||
}
|
||||
|
||||
// WithToken creates a new context with the given user
|
||||
func WithToken(ctx context.Context, tok *database.Token) context.Context {
|
||||
return context.WithValue(ctx, tokenKey, tok)
|
||||
}
|
||||
|
||||
// User retrieves a user from the given context. It returns a pointer to
|
||||
// a user. If the context does not contain a user, it returns nil.
|
||||
func User(ctx context.Context) *database.User {
|
||||
if temp := ctx.Value(userKey); temp != nil {
|
||||
if user, ok := temp.(*database.User); ok {
|
||||
return user
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Account retrieves an account from the given context.
|
||||
func Account(ctx context.Context) *database.Account {
|
||||
if temp := ctx.Value(accountKey); temp != nil {
|
||||
if account, ok := temp.(*database.Account); ok {
|
||||
return account
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Token retrieves a token from the given context.
|
||||
func Token(ctx context.Context) *database.Token {
|
||||
if temp := ctx.Value(tokenKey); temp != nil {
|
||||
if tok, ok := temp.(*database.Token); ok {
|
||||
return tok
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
297
pkg/server/controllers/books.go
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
package controllers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/dnote/dnote/pkg/server/app"
|
||||
"github.com/dnote/dnote/pkg/server/context"
|
||||
"github.com/dnote/dnote/pkg/server/database"
|
||||
"github.com/dnote/dnote/pkg/server/helpers"
|
||||
"github.com/dnote/dnote/pkg/server/presenters"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// NewBooks creates a new Books controller.
|
||||
// It panics if the necessary templates are not parsed.
|
||||
func NewBooks(app *app.App) *Books {
|
||||
return &Books{
|
||||
app: app,
|
||||
}
|
||||
}
|
||||
|
||||
// Books is a user controller.
|
||||
type Books struct {
|
||||
app *app.App
|
||||
}
|
||||
|
||||
func (b *Books) getBooks(r *http.Request) ([]database.Book, error) {
|
||||
user := context.User(r.Context())
|
||||
if user == nil {
|
||||
return []database.Book{}, app.ErrLoginRequired
|
||||
}
|
||||
|
||||
conn := b.app.DB.Where("user_id = ? AND NOT deleted", user.ID).Order("label ASC")
|
||||
|
||||
query := r.URL.Query()
|
||||
name := query.Get("name")
|
||||
encryptedStr := query.Get("encrypted")
|
||||
|
||||
if name != "" {
|
||||
part := fmt.Sprintf("%%%s%%", name)
|
||||
conn = conn.Where("LOWER(label) LIKE ?", part)
|
||||
}
|
||||
if encryptedStr != "" {
|
||||
var encrypted bool
|
||||
if encryptedStr == "true" {
|
||||
encrypted = true
|
||||
} else {
|
||||
encrypted = false
|
||||
}
|
||||
|
||||
conn = conn.Where("encrypted = ?", encrypted)
|
||||
}
|
||||
|
||||
var books []database.Book
|
||||
if err := conn.Find(&books).Error; err != nil {
|
||||
return []database.Book{}, nil
|
||||
}
|
||||
|
||||
return books, nil
|
||||
}
|
||||
|
||||
// V3Index gets books
|
||||
func (b *Books) V3Index(w http.ResponseWriter, r *http.Request) {
|
||||
result, err := b.getBooks(r)
|
||||
if err != nil {
|
||||
handleJSONError(w, err, "getting books")
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, presenters.PresentBooks(result))
|
||||
}
|
||||
|
||||
// V3Show gets a book
|
||||
func (b *Books) V3Show(w http.ResponseWriter, r *http.Request) {
|
||||
user := context.User(r.Context())
|
||||
if user == nil {
|
||||
handleJSONError(w, app.ErrLoginRequired, "login required")
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
bookUUID := vars["bookUUID"]
|
||||
|
||||
if !helpers.ValidateUUID(bookUUID) {
|
||||
handleJSONError(w, app.ErrInvalidUUID, "login required")
|
||||
return
|
||||
}
|
||||
|
||||
var book database.Book
|
||||
conn := b.app.DB.Where("uuid = ? AND user_id = ?", bookUUID, user.ID).First(&book)
|
||||
|
||||
if conn.RecordNotFound() {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err := conn.Error; err != nil {
|
||||
handleJSONError(w, err, "finding the book")
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, presenters.PresentBook(book))
|
||||
}
|
||||
|
||||
type createBookPayload struct {
|
||||
Name string `schema:"name" json:"name"`
|
||||
}
|
||||
|
||||
func validateCreateBookPayload(p createBookPayload) error {
|
||||
if p.Name == "" {
|
||||
return app.ErrBookNameRequired
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Books) create(r *http.Request) (database.Book, error) {
|
||||
user := context.User(r.Context())
|
||||
if user == nil {
|
||||
return database.Book{}, app.ErrLoginRequired
|
||||
}
|
||||
|
||||
var params createBookPayload
|
||||
if err := parseRequestData(r, ¶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")
|
||||
}
|
||||
|
|
@ -16,17 +16,19 @@
|
|||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package api
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/dnote/dnote/pkg/assert"
|
||||
"github.com/dnote/dnote/pkg/clock"
|
||||
"github.com/dnote/dnote/pkg/server/app"
|
||||
"github.com/dnote/dnote/pkg/server/config"
|
||||
"github.com/dnote/dnote/pkg/server/database"
|
||||
"github.com/dnote/dnote/pkg/server/presenters"
|
||||
"github.com/dnote/dnote/pkg/server/testutils"
|
||||
|
|
@ -34,18 +36,21 @@ import (
|
|||
)
|
||||
|
||||
func TestGetBooks(t *testing.T) {
|
||||
|
||||
defer testutils.ClearData(testutils.DB)
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
|
||||
Clock: clock.NewMock(),
|
||||
Config: config.Config{
|
||||
PageTemplateDir: "../views",
|
||||
},
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
user := testutils.SetupUserData()
|
||||
testutils.SetupAccountData(user, "alice@test.com", "pass1234")
|
||||
anotherUser := testutils.SetupUserData()
|
||||
testutils.SetupAccountData(anotherUser, "bob@test.com", "pass1234")
|
||||
|
||||
b1 := database.Book{
|
||||
UserID: user.ID,
|
||||
|
|
@ -77,7 +82,9 @@ func TestGetBooks(t *testing.T) {
|
|||
testutils.MustExec(t, testutils.DB.Save(&b4), "preparing b4")
|
||||
|
||||
// Execute
|
||||
req := testutils.MakeReq(server.URL, "GET", "/v3/books", "")
|
||||
endpoint := "/api/v3/books"
|
||||
|
||||
req := testutils.MakeReq(server.URL, "GET", endpoint, "")
|
||||
res := testutils.HTTPAuthDo(t, req, user)
|
||||
|
||||
// Test
|
||||
|
|
@ -114,19 +121,21 @@ func TestGetBooks(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestGetBooksByName(t *testing.T) {
|
||||
|
||||
defer testutils.ClearData(testutils.DB)
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
|
||||
Clock: clock.NewMock(),
|
||||
Config: config.Config{
|
||||
PageTemplateDir: "../views",
|
||||
},
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
user := testutils.SetupUserData()
|
||||
testutils.SetupAccountData(user, "alice@test.com", "pass1234")
|
||||
anotherUser := testutils.SetupUserData()
|
||||
req := testutils.MakeReq(server.URL, "GET", "/v3/books?name=js", "")
|
||||
testutils.SetupAccountData(anotherUser, "bob@test.com", "pass1234")
|
||||
|
||||
b1 := database.Book{
|
||||
UserID: user.ID,
|
||||
|
|
@ -145,6 +154,9 @@ func TestGetBooksByName(t *testing.T) {
|
|||
testutils.MustExec(t, testutils.DB.Save(&b3), "preparing b3")
|
||||
|
||||
// Execute
|
||||
endpoint := "/api/v3/books?name=js"
|
||||
|
||||
req := testutils.MakeReq(server.URL, "GET", endpoint, "")
|
||||
res := testutils.HTTPAuthDo(t, req, user)
|
||||
|
||||
// Test
|
||||
|
|
@ -171,6 +183,315 @@ func TestGetBooksByName(t *testing.T) {
|
|||
assert.DeepEqual(t, payload, expected, "payload mismatch")
|
||||
}
|
||||
|
||||
func TestGetBook(t *testing.T) {
|
||||
defer testutils.ClearData(testutils.DB)
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
Clock: clock.NewMock(),
|
||||
Config: config.Config{
|
||||
PageTemplateDir: "../views",
|
||||
},
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
user := testutils.SetupUserData()
|
||||
testutils.SetupAccountData(user, "alice@test.com", "pass1234")
|
||||
anotherUser := testutils.SetupUserData()
|
||||
testutils.SetupAccountData(anotherUser, "bob@test.com", "pass1234")
|
||||
|
||||
b1 := database.Book{
|
||||
UserID: user.ID,
|
||||
Label: "js",
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&b1), "preparing b1")
|
||||
b2 := database.Book{
|
||||
UserID: user.ID,
|
||||
Label: "css",
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&b2), "preparing b2")
|
||||
b3 := database.Book{
|
||||
UserID: anotherUser.ID,
|
||||
Label: "js",
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&b3), "preparing b3")
|
||||
|
||||
// Execute
|
||||
endpoint := fmt.Sprintf("/api/v3/books/%s", b1.UUID)
|
||||
req := testutils.MakeReq(server.URL, "GET", endpoint, "")
|
||||
res := testutils.HTTPAuthDo(t, req, user)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusOK, "")
|
||||
|
||||
var payload presenters.Book
|
||||
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
|
||||
t.Fatal(errors.Wrap(err, "decoding payload"))
|
||||
}
|
||||
|
||||
var b1Record database.Book
|
||||
testutils.MustExec(t, testutils.DB.Where("id = ?", b1.ID).First(&b1Record), "finding b1")
|
||||
|
||||
expected := presenters.Book{
|
||||
UUID: b1Record.UUID,
|
||||
CreatedAt: b1Record.CreatedAt,
|
||||
UpdatedAt: b1Record.UpdatedAt,
|
||||
Label: b1Record.Label,
|
||||
USN: b1Record.USN,
|
||||
}
|
||||
|
||||
assert.DeepEqual(t, payload, expected, "payload mismatch")
|
||||
}
|
||||
|
||||
func TestGetBookNonOwner(t *testing.T) {
|
||||
defer testutils.ClearData(testutils.DB)
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
Clock: clock.NewMock(),
|
||||
Config: config.Config{
|
||||
PageTemplateDir: "../views",
|
||||
},
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
user := testutils.SetupUserData()
|
||||
testutils.SetupAccountData(user, "alice@test.com", "pass1234")
|
||||
nonOwner := testutils.SetupUserData()
|
||||
testutils.SetupAccountData(nonOwner, "bob@test.com", "pass1234")
|
||||
|
||||
b1 := database.Book{
|
||||
UserID: user.ID,
|
||||
Label: "js",
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&b1), "preparing b1")
|
||||
|
||||
// Execute
|
||||
endpoint := fmt.Sprintf("/api/v3/books/%s", b1.UUID)
|
||||
req := testutils.MakeReq(server.URL, "GET", endpoint, "")
|
||||
res := testutils.HTTPAuthDo(t, req, nonOwner)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusNotFound, "")
|
||||
|
||||
body, err := ioutil.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
t.Fatal(errors.Wrap(err, "reading body"))
|
||||
}
|
||||
assert.DeepEqual(t, string(body), "", "payload mismatch")
|
||||
}
|
||||
|
||||
func TestCreateBook(t *testing.T) {
|
||||
t.Run("success", func(t *testing.T) {
|
||||
defer testutils.ClearData(testutils.DB)
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
Clock: clock.NewMock(),
|
||||
Config: config.Config{
|
||||
PageTemplateDir: "../views",
|
||||
},
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
user := testutils.SetupUserData()
|
||||
testutils.SetupAccountData(user, "alice@test.com", "pass1234")
|
||||
testutils.MustExec(t, testutils.DB.Model(&user).Update("max_usn", 101), "preparing user max_usn")
|
||||
|
||||
req := testutils.MakeReq(server.URL, "POST", "/api/v3/books", `{"name": "js"}`)
|
||||
|
||||
// Execute
|
||||
res := testutils.HTTPAuthDo(t, req, user)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusCreated, "")
|
||||
|
||||
var bookRecord database.Book
|
||||
var userRecord database.User
|
||||
var bookCount, noteCount int
|
||||
testutils.MustExec(t, testutils.DB.Model(&database.Book{}).Count(&bookCount), "counting books")
|
||||
testutils.MustExec(t, testutils.DB.Model(&database.Note{}).Count(¬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))
|
||||
}()
|
||||
}
|
||||
}
|
||||
32
pkg/server/controllers/controllers.go
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
package controllers
|
||||
|
||||
import (
|
||||
"github.com/dnote/dnote/pkg/server/app"
|
||||
"github.com/dnote/dnote/pkg/server/log"
|
||||
)
|
||||
|
||||
// Controllers is a group of controllers
|
||||
type Controllers struct {
|
||||
Users *Users
|
||||
Notes *Notes
|
||||
Books *Books
|
||||
Sync *Sync
|
||||
Static *Static
|
||||
Health *Health
|
||||
}
|
||||
|
||||
// New returns a new group of controllers
|
||||
func New(app *app.App, baseDir string) *Controllers {
|
||||
log.Info(app.Config.PageTemplateDir)
|
||||
|
||||
c := Controllers{}
|
||||
|
||||
c.Users = NewUsers(app, baseDir)
|
||||
c.Notes = NewNotes(app)
|
||||
c.Books = NewBooks(app)
|
||||
c.Sync = NewSync(app)
|
||||
c.Static = NewStatic(app, baseDir)
|
||||
c.Health = NewHealth(app)
|
||||
|
||||
return &c
|
||||
}
|
||||
23
pkg/server/controllers/health.go
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
package controllers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/dnote/dnote/pkg/server/app"
|
||||
)
|
||||
|
||||
// NewHealth creates a new Health controller.
|
||||
// It panics if the necessary templates are not parsed.
|
||||
func NewHealth(app *app.App) *Health {
|
||||
return &Health{}
|
||||
}
|
||||
|
||||
// Health is a health controller.
|
||||
type Health struct {
|
||||
}
|
||||
|
||||
// Index handles GET /
|
||||
func (n *Health) Index(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("ok"))
|
||||
}
|
||||
|
|
@ -16,29 +16,30 @@
|
|||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package api
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/dnote/dnote/pkg/assert"
|
||||
"github.com/dnote/dnote/pkg/clock"
|
||||
"github.com/dnote/dnote/pkg/server/app"
|
||||
"github.com/dnote/dnote/pkg/server/config"
|
||||
"github.com/dnote/dnote/pkg/server/testutils"
|
||||
"github.com/jinzhu/gorm"
|
||||
)
|
||||
|
||||
func TestCheckHealth(t *testing.T) {
|
||||
// Setup
|
||||
func TestHealth(t *testing.T) {
|
||||
defer testutils.ClearData(testutils.DB)
|
||||
|
||||
server := MustNewServer(t, &app.App{
|
||||
DB: &gorm.DB{},
|
||||
Clock: clock.NewMock(),
|
||||
Config: config.Config{
|
||||
PageTemplateDir: "../views",
|
||||
},
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
// Execute
|
||||
req := testutils.MakeReq(server.URL, "GET", "/health", "")
|
||||
req := testutils.MakeReq(server.URL, "GET", "/api/health", "")
|
||||
res := testutils.HTTPDo(t, req)
|
||||
|
||||
// Test
|
||||
315
pkg/server/controllers/helpers.go
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
package controllers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dnote/dnote/pkg/server/app"
|
||||
"github.com/dnote/dnote/pkg/server/consts"
|
||||
"github.com/dnote/dnote/pkg/server/database"
|
||||
"github.com/dnote/dnote/pkg/server/log"
|
||||
"github.com/dnote/dnote/pkg/server/views"
|
||||
"github.com/gorilla/schema"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func parseRequestData(r *http.Request, dst interface{}) error {
|
||||
ct := r.Header.Get("Content-Type")
|
||||
|
||||
if ct == consts.ContentTypeForm {
|
||||
if err := parseForm(r, dst); err != nil {
|
||||
return errors.Wrap(err, "parsing form")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// default to JSON
|
||||
if err := parseJSON(r, dst); err != nil {
|
||||
return errors.Wrap(err, "parsing JSON")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseForm(r *http.Request, dst interface{}) error {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return parseValues(r.PostForm, dst)
|
||||
}
|
||||
|
||||
func parseURLParams(r *http.Request, dst interface{}) error {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
return err
|
||||
}
|
||||
return parseValues(r.Form, dst)
|
||||
}
|
||||
|
||||
func parseValues(values url.Values, dst interface{}) error {
|
||||
dec := schema.NewDecoder()
|
||||
|
||||
// Ignore CSRF token field
|
||||
dec.IgnoreUnknownKeys(true)
|
||||
|
||||
if err := dec.Decode(dst, values); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseJSON(r *http.Request, dst interface{}) error {
|
||||
dec := json.NewDecoder(r.Body)
|
||||
|
||||
if err := dec.Decode(dst); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCredential extracts a session key from the request from the request header. Concretely,
|
||||
// it first looks at the 'Cookie' and then the 'Authorization' header. If no credential is found,
|
||||
// it returns an empty string.
|
||||
func GetCredential(r *http.Request) (string, error) {
|
||||
ret, err := getSessionKeyFromCookie(r)
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "getting session key from cookie")
|
||||
}
|
||||
if ret != "" {
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
ret, err = getSessionKeyFromAuth(r)
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "getting session key from Authorization header")
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
// getSessionKeyFromCookie reads and returns a session key from the cookie sent by the
|
||||
// request. If no session key is found, it returns an empty string
|
||||
func getSessionKeyFromCookie(r *http.Request) (string, error) {
|
||||
c, err := r.Cookie("id")
|
||||
|
||||
if err == http.ErrNoCookie {
|
||||
return "", nil
|
||||
} else if err != nil {
|
||||
return "", errors.Wrap(err, "reading cookie")
|
||||
}
|
||||
|
||||
return c.Value, nil
|
||||
}
|
||||
|
||||
// getSessionKeyFromAuth reads and returns a session key from the Authorization header
|
||||
func getSessionKeyFromAuth(r *http.Request) (string, error) {
|
||||
h := r.Header.Get("Authorization")
|
||||
if h == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
payload, err := parseAuthHeader(h)
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "parsing the authorization header")
|
||||
}
|
||||
if payload.scheme != "Bearer" {
|
||||
return "", errors.New("unsupported scheme")
|
||||
}
|
||||
|
||||
return payload.credential, nil
|
||||
}
|
||||
|
||||
func parseAuthHeader(h string) (authHeader, error) {
|
||||
parts := strings.Split(h, " ")
|
||||
|
||||
if len(parts) != 2 {
|
||||
return authHeader{}, errors.New("Invalid authorization header")
|
||||
}
|
||||
|
||||
parsed := authHeader{
|
||||
scheme: parts[0],
|
||||
credential: parts[1],
|
||||
}
|
||||
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
type authHeader struct {
|
||||
scheme string
|
||||
credential string
|
||||
}
|
||||
|
||||
const (
|
||||
sessionCookieName = "id"
|
||||
sessionCookiePath = "/"
|
||||
)
|
||||
|
||||
func setSessionCookie(w http.ResponseWriter, key string, expires time.Time) {
|
||||
cookie := http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: key,
|
||||
Expires: expires,
|
||||
Path: sessionCookiePath,
|
||||
HttpOnly: true,
|
||||
}
|
||||
http.SetCookie(w, &cookie)
|
||||
}
|
||||
|
||||
func unsetSessionCookie(w http.ResponseWriter) {
|
||||
expires := time.Now().Add(time.Hour * -24 * 30)
|
||||
cookie := http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: "",
|
||||
Expires: expires,
|
||||
Path: sessionCookiePath,
|
||||
HttpOnly: true,
|
||||
}
|
||||
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
http.SetCookie(w, &cookie)
|
||||
}
|
||||
|
||||
// SessionResponse is a response containing a session information
|
||||
type SessionResponse struct {
|
||||
Key string `json:"key"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
}
|
||||
|
||||
func logError(err error, msg string) {
|
||||
// log if internal error
|
||||
// if _, ok := err.(views.PublicError); !ok {
|
||||
// log.ErrorWrap(err, msg)
|
||||
// }
|
||||
log.ErrorWrap(err, msg)
|
||||
}
|
||||
|
||||
func getStatusCode(err error) int {
|
||||
rootErr := errors.Cause(err)
|
||||
|
||||
switch rootErr {
|
||||
case app.ErrNotFound:
|
||||
return http.StatusNotFound
|
||||
case app.ErrLoginInvalid:
|
||||
return http.StatusUnauthorized
|
||||
case app.ErrDuplicateEmail, app.ErrEmailRequired, app.ErrPasswordTooShort:
|
||||
return http.StatusBadRequest
|
||||
case app.ErrLoginRequired:
|
||||
return http.StatusUnauthorized
|
||||
case app.ErrBookUUIDRequired:
|
||||
return http.StatusBadRequest
|
||||
case app.ErrEmptyUpdate:
|
||||
return http.StatusBadRequest
|
||||
case app.ErrInvalidUUID:
|
||||
return http.StatusBadRequest
|
||||
case app.ErrDuplicateBook:
|
||||
return http.StatusConflict
|
||||
case app.ErrInvalidToken:
|
||||
return http.StatusBadRequest
|
||||
case app.ErrPasswordResetTokenExpired:
|
||||
return http.StatusGone
|
||||
case app.ErrPasswordConfirmationMismatch:
|
||||
return http.StatusBadRequest
|
||||
case app.ErrInvalidPasswordChangeInput:
|
||||
return http.StatusBadRequest
|
||||
case app.ErrInvalidPassword:
|
||||
return http.StatusUnauthorized
|
||||
case app.ErrEmailTooLong:
|
||||
return http.StatusBadRequest
|
||||
case app.ErrEmailAlreadyVerified:
|
||||
return http.StatusConflict
|
||||
case app.ErrMissingToken:
|
||||
return http.StatusBadRequest
|
||||
case app.ErrExpiredToken:
|
||||
return http.StatusGone
|
||||
}
|
||||
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
|
||||
// handleHTMLError writes the error to the log and sets the error message in the data.
|
||||
func handleHTMLError(w http.ResponseWriter, r *http.Request, err error, msg string, v *views.View, d views.Data) {
|
||||
statusCode := getStatusCode(err)
|
||||
|
||||
logError(err, msg)
|
||||
|
||||
d.SetAlert(err, v.AlertInBody)
|
||||
v.Render(w, r, &d, statusCode)
|
||||
}
|
||||
|
||||
// handleJSONError logs the error and responds with the given status code with a generic status text
|
||||
func handleJSONError(w http.ResponseWriter, err error, msg string) {
|
||||
statusCode := getStatusCode(err)
|
||||
|
||||
rootErr := errors.Cause(err)
|
||||
|
||||
var respText string
|
||||
if pErr, ok := rootErr.(views.PublicError); ok {
|
||||
respText = pErr.Public()
|
||||
} else {
|
||||
respText = http.StatusText(statusCode)
|
||||
}
|
||||
|
||||
logError(err, msg)
|
||||
http.Error(w, respText, statusCode)
|
||||
}
|
||||
|
||||
// respondWithSession makes a HTTP response with the session from the user with the given userID.
|
||||
// It sets the HTTP-Only cookie for browser clients and also sends a JSON response for non-browser clients.
|
||||
func respondWithSession(w http.ResponseWriter, statusCode int, session *database.Session) {
|
||||
setSessionCookie(w, session.Key, session.ExpiresAt)
|
||||
|
||||
response := SessionResponse{
|
||||
Key: session.Key,
|
||||
ExpiresAt: session.ExpiresAt.Unix(),
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
dat, err := json.Marshal(response)
|
||||
if err != nil {
|
||||
handleJSONError(w, err, "encoding response")
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(statusCode)
|
||||
w.Write(dat)
|
||||
}
|
||||
|
||||
// respondJSON encodes the given payload into a JSON format and writes it to the given response writer
|
||||
func respondJSON(w http.ResponseWriter, statusCode int, payload interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
dat, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
handleJSONError(w, err, "encoding response")
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(statusCode)
|
||||
w.Write(dat)
|
||||
}
|
||||
|
||||
func getClientType(r *http.Request) string {
|
||||
origin := r.Header.Get("Origin")
|
||||
|
||||
if strings.HasPrefix(origin, "moz-extension://") {
|
||||
return "firefox-extension"
|
||||
}
|
||||
|
||||
if strings.HasPrefix(origin, "chrome-extension://") {
|
||||
return "chrome-extension"
|
||||
}
|
||||
|
||||
userAgent := r.Header.Get("User-Agent")
|
||||
if strings.HasPrefix(userAgent, "Go-http-client") {
|
||||
return "cli"
|
||||
}
|
||||
|
||||
return "web"
|
||||
}
|
||||
|
|
@ -16,7 +16,7 @@
|
|||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package remind
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"os"
|
||||
446
pkg/server/controllers/notes.go
Normal file
|
|
@ -0,0 +1,446 @@
|
|||
package controllers
|
||||
|
||||
import (
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dnote/dnote/pkg/server/app"
|
||||
"github.com/dnote/dnote/pkg/server/context"
|
||||
"github.com/dnote/dnote/pkg/server/database"
|
||||
"github.com/dnote/dnote/pkg/server/operations"
|
||||
"github.com/dnote/dnote/pkg/server/presenters"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// NewNotes creates a new Notes controller.
|
||||
// It panics if the necessary templates are not parsed.
|
||||
func NewNotes(app *app.App) *Notes {
|
||||
return &Notes{
|
||||
app: app,
|
||||
}
|
||||
}
|
||||
|
||||
var notesPerPage = 30
|
||||
|
||||
// Notes is a user controller.
|
||||
type Notes struct {
|
||||
app *app.App
|
||||
}
|
||||
|
||||
// escapeSearchQuery escapes the query for full text search
|
||||
func escapeSearchQuery(searchQuery string) string {
|
||||
return strings.Join(strings.Fields(searchQuery), "&")
|
||||
}
|
||||
|
||||
func parseSearchQuery(q url.Values) string {
|
||||
searchStr := q.Get("q")
|
||||
|
||||
return escapeSearchQuery(searchStr)
|
||||
}
|
||||
|
||||
func parsePageQuery(q url.Values) (int, error) {
|
||||
pageStr := q.Get("page")
|
||||
if len(pageStr) == 0 {
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
p, err := strconv.Atoi(pageStr)
|
||||
return p, err
|
||||
}
|
||||
|
||||
func parseGetNotesQuery(q url.Values) (app.GetNotesParams, error) {
|
||||
yearStr := q.Get("year")
|
||||
monthStr := q.Get("month")
|
||||
books := q["book"]
|
||||
encryptedStr := q.Get("encrypted")
|
||||
pageStr := q.Get("page")
|
||||
|
||||
page, err := parsePageQuery(q)
|
||||
if err != nil {
|
||||
return app.GetNotesParams{}, errors.Errorf("invalid page %s", pageStr)
|
||||
}
|
||||
if page < 1 {
|
||||
return app.GetNotesParams{}, errors.Errorf("invalid page %s", pageStr)
|
||||
}
|
||||
|
||||
var year int
|
||||
if len(yearStr) > 0 {
|
||||
y, err := strconv.Atoi(yearStr)
|
||||
if err != nil {
|
||||
return app.GetNotesParams{}, errors.Errorf("invalid year %s", yearStr)
|
||||
}
|
||||
|
||||
year = y
|
||||
}
|
||||
|
||||
var month int
|
||||
if len(monthStr) > 0 {
|
||||
m, err := strconv.Atoi(monthStr)
|
||||
if err != nil {
|
||||
return app.GetNotesParams{}, errors.Errorf("invalid month %s", monthStr)
|
||||
}
|
||||
if m < 1 || m > 12 {
|
||||
return app.GetNotesParams{}, errors.Errorf("invalid month %s", monthStr)
|
||||
}
|
||||
|
||||
month = m
|
||||
}
|
||||
|
||||
var encrypted bool
|
||||
if strings.ToLower(encryptedStr) == "true" {
|
||||
encrypted = true
|
||||
} else {
|
||||
encrypted = false
|
||||
}
|
||||
|
||||
ret := app.GetNotesParams{
|
||||
Year: year,
|
||||
Month: month,
|
||||
Page: page,
|
||||
Search: parseSearchQuery(q),
|
||||
Books: books,
|
||||
Encrypted: encrypted,
|
||||
PerPage: notesPerPage,
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (n *Notes) getNotes(r *http.Request) (app.GetNotesResult, app.GetNotesParams, error) {
|
||||
user := context.User(r.Context())
|
||||
if user == nil {
|
||||
return app.GetNotesResult{}, app.GetNotesParams{}, app.ErrLoginRequired
|
||||
}
|
||||
|
||||
query := r.URL.Query()
|
||||
p, err := parseGetNotesQuery(query)
|
||||
if err != nil {
|
||||
return app.GetNotesResult{}, app.GetNotesParams{}, errors.Wrap(err, "parsing query")
|
||||
}
|
||||
|
||||
res, err := n.app.GetNotes(user.ID, p)
|
||||
if err != nil {
|
||||
return app.GetNotesResult{}, app.GetNotesParams{}, errors.Wrap(err, "getting notes")
|
||||
}
|
||||
|
||||
return res, p, nil
|
||||
}
|
||||
|
||||
type noteGroup struct {
|
||||
Year int
|
||||
Month int
|
||||
Data []database.Note
|
||||
}
|
||||
|
||||
type bucketKey struct {
|
||||
year int
|
||||
month time.Month
|
||||
}
|
||||
|
||||
func groupNotes(notes []database.Note) []noteGroup {
|
||||
ret := []noteGroup{}
|
||||
|
||||
buckets := map[bucketKey][]database.Note{}
|
||||
|
||||
for _, note := range notes {
|
||||
year := note.UpdatedAt.Year()
|
||||
month := note.UpdatedAt.Month()
|
||||
key := bucketKey{year, month}
|
||||
|
||||
if _, ok := buckets[key]; !ok {
|
||||
buckets[key] = []database.Note{}
|
||||
}
|
||||
|
||||
buckets[key] = append(buckets[key], note)
|
||||
}
|
||||
|
||||
keys := []bucketKey{}
|
||||
for key := range buckets {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
yearI := keys[i].year
|
||||
yearJ := keys[j].year
|
||||
monthI := keys[i].month
|
||||
monthJ := keys[j].month
|
||||
|
||||
if yearI == yearJ {
|
||||
return monthI < monthJ
|
||||
}
|
||||
|
||||
return yearI < yearJ
|
||||
})
|
||||
|
||||
for _, key := range keys {
|
||||
group := noteGroup{
|
||||
Year: key.year,
|
||||
Month: int(key.month),
|
||||
Data: buckets[key],
|
||||
}
|
||||
ret = append(ret, group)
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
func getMaxPage(page, total int) int {
|
||||
tmp := float64(total) / float64(notesPerPage)
|
||||
return int(math.Ceil(tmp))
|
||||
}
|
||||
|
||||
// GetNotesResponse is a reponse by getNotesHandler
|
||||
type GetNotesResponse struct {
|
||||
Notes []presenters.Note `json:"notes"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// V3Index is a v3 handler for getting notes
|
||||
func (n *Notes) V3Index(w http.ResponseWriter, r *http.Request) {
|
||||
result, _, err := n.getNotes(r)
|
||||
if err != nil {
|
||||
handleJSONError(w, err, "getting notes")
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, GetNotesResponse{
|
||||
Notes: presenters.PresentNotes(result.Notes),
|
||||
Total: result.Total,
|
||||
})
|
||||
}
|
||||
|
||||
func (n *Notes) getNote(r *http.Request) (database.Note, error) {
|
||||
user := context.User(r.Context())
|
||||
|
||||
vars := mux.Vars(r)
|
||||
noteUUID := vars["noteUUID"]
|
||||
|
||||
note, ok, err := operations.GetNote(n.app.DB, noteUUID, user)
|
||||
if !ok {
|
||||
return database.Note{}, app.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return database.Note{}, errors.Wrap(err, "finding note")
|
||||
}
|
||||
|
||||
return note, nil
|
||||
}
|
||||
|
||||
// V3Show is api for show
|
||||
func (n *Notes) V3Show(w http.ResponseWriter, r *http.Request) {
|
||||
note, err := n.getNote(r)
|
||||
if err != nil {
|
||||
handleJSONError(w, err, "getting note")
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, presenters.PresentNote(note))
|
||||
}
|
||||
|
||||
type createNotePayload struct {
|
||||
BookUUID string `schema:"book_uuid" json:"book_uuid"`
|
||||
Content string `schema:"content" json:"content"`
|
||||
AddedOn *int64 `schema:"added_on" json:"added_on"`
|
||||
EditedOn *int64 `schema:"edited_on" json:"edited_on"`
|
||||
}
|
||||
|
||||
func validateCreateNotePayload(p createNotePayload) error {
|
||||
if p.BookUUID == "" {
|
||||
return app.ErrBookUUIDRequired
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *Notes) create(r *http.Request) (database.Note, error) {
|
||||
user := context.User(r.Context())
|
||||
if user == nil {
|
||||
return database.Note{}, app.ErrLoginRequired
|
||||
}
|
||||
|
||||
var params createNotePayload
|
||||
if err := parseRequestData(r, ¶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")
|
||||
}
|
||||
770
pkg/server/controllers/notes_test.go
Normal file
|
|
@ -0,0 +1,770 @@
|
|||
/* Copyright (C) 2019, 2020 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dnote/dnote/pkg/assert"
|
||||
"github.com/dnote/dnote/pkg/clock"
|
||||
"github.com/dnote/dnote/pkg/server/app"
|
||||
"github.com/dnote/dnote/pkg/server/config"
|
||||
"github.com/dnote/dnote/pkg/server/database"
|
||||
"github.com/dnote/dnote/pkg/server/presenters"
|
||||
"github.com/dnote/dnote/pkg/server/testutils"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func getExpectedNotePayload(n database.Note, b database.Book, u database.User) presenters.Note {
|
||||
return presenters.Note{
|
||||
UUID: n.UUID,
|
||||
CreatedAt: n.CreatedAt,
|
||||
UpdatedAt: n.UpdatedAt,
|
||||
Body: n.Body,
|
||||
AddedOn: n.AddedOn,
|
||||
Public: n.Public,
|
||||
USN: n.USN,
|
||||
Book: presenters.NoteBook{
|
||||
UUID: b.UUID,
|
||||
Label: b.Label,
|
||||
},
|
||||
User: presenters.NoteUser{
|
||||
UUID: u.UUID,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetNotes(t *testing.T) {
|
||||
defer testutils.ClearData(testutils.DB)
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
Clock: clock.NewMock(),
|
||||
Config: config.Config{
|
||||
PageTemplateDir: "../views",
|
||||
},
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
user := testutils.SetupUserData()
|
||||
testutils.SetupAccountData(user, "alice@test.com", "pass1234")
|
||||
anotherUser := testutils.SetupUserData()
|
||||
testutils.SetupAccountData(anotherUser, "bob@test.com", "pass1234")
|
||||
|
||||
b1 := database.Book{
|
||||
UserID: user.ID,
|
||||
Label: "js",
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&b1), "preparing b1")
|
||||
b2 := database.Book{
|
||||
UserID: user.ID,
|
||||
Label: "css",
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&b2), "preparing b2")
|
||||
b3 := database.Book{
|
||||
UserID: anotherUser.ID,
|
||||
Label: "css",
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&b3), "preparing b3")
|
||||
|
||||
n1 := database.Note{
|
||||
UserID: user.ID,
|
||||
BookUUID: b1.UUID,
|
||||
Body: "n1 content",
|
||||
USN: 11,
|
||||
Deleted: false,
|
||||
AddedOn: time.Date(2018, time.August, 10, 23, 0, 0, 0, time.UTC).UnixNano(),
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&n1), "preparing n1")
|
||||
n2 := database.Note{
|
||||
UserID: user.ID,
|
||||
BookUUID: b1.UUID,
|
||||
Body: "n2 content",
|
||||
USN: 14,
|
||||
Deleted: false,
|
||||
AddedOn: time.Date(2018, time.August, 11, 22, 0, 0, 0, time.UTC).UnixNano(),
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&n2), "preparing n2")
|
||||
n3 := database.Note{
|
||||
UserID: user.ID,
|
||||
BookUUID: b1.UUID,
|
||||
Body: "n3 content",
|
||||
USN: 17,
|
||||
Deleted: false,
|
||||
AddedOn: time.Date(2017, time.January, 10, 23, 0, 0, 0, time.UTC).UnixNano(),
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&n3), "preparing n3")
|
||||
n4 := database.Note{
|
||||
UserID: user.ID,
|
||||
BookUUID: b2.UUID,
|
||||
Body: "n4 content",
|
||||
USN: 18,
|
||||
Deleted: false,
|
||||
AddedOn: time.Date(2018, time.September, 10, 23, 0, 0, 0, time.UTC).UnixNano(),
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&n4), "preparing n4")
|
||||
n5 := database.Note{
|
||||
UserID: anotherUser.ID,
|
||||
BookUUID: b3.UUID,
|
||||
Body: "n5 content",
|
||||
USN: 19,
|
||||
Deleted: false,
|
||||
AddedOn: time.Date(2018, time.August, 10, 23, 0, 0, 0, time.UTC).UnixNano(),
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&n5), "preparing n5")
|
||||
n6 := database.Note{
|
||||
UserID: user.ID,
|
||||
BookUUID: b1.UUID,
|
||||
Body: "",
|
||||
USN: 11,
|
||||
Deleted: true,
|
||||
AddedOn: time.Date(2018, time.August, 10, 23, 0, 0, 0, time.UTC).UnixNano(),
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&n6), "preparing n6")
|
||||
|
||||
// Execute
|
||||
endpoint := "/api/v3/notes"
|
||||
|
||||
req := testutils.MakeReq(server.URL, "GET", fmt.Sprintf("%s?year=2018&month=8", endpoint), "")
|
||||
res := testutils.HTTPAuthDo(t, req, user)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusOK, "")
|
||||
|
||||
var payload GetNotesResponse
|
||||
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
|
||||
t.Fatal(errors.Wrap(err, "decoding payload"))
|
||||
}
|
||||
|
||||
var n2Record, n1Record database.Note
|
||||
testutils.MustExec(t, testutils.DB.Where("uuid = ?", n2.UUID).First(&n2Record), "finding n2Record")
|
||||
testutils.MustExec(t, testutils.DB.Where("uuid = ?", n1.UUID).First(&n1Record), "finding n1Record")
|
||||
|
||||
expected := GetNotesResponse{
|
||||
Notes: []presenters.Note{
|
||||
getExpectedNotePayload(n2Record, b1, user),
|
||||
getExpectedNotePayload(n1Record, b1, user),
|
||||
},
|
||||
Total: 2,
|
||||
}
|
||||
|
||||
assert.DeepEqual(t, payload, expected, "payload mismatch")
|
||||
}
|
||||
|
||||
func TestGetNote(t *testing.T) {
|
||||
defer testutils.ClearData(testutils.DB)
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
Clock: clock.NewMock(),
|
||||
Config: config.Config{
|
||||
PageTemplateDir: "../views",
|
||||
},
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
user := testutils.SetupUserData()
|
||||
anotherUser := testutils.SetupUserData()
|
||||
|
||||
b1 := database.Book{
|
||||
UserID: user.ID,
|
||||
Label: "js",
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&b1), "preparing b1")
|
||||
|
||||
privateNote := database.Note{
|
||||
UserID: user.ID,
|
||||
BookUUID: b1.UUID,
|
||||
Body: "privateNote content",
|
||||
Public: false,
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&privateNote), "preparing privateNote")
|
||||
publicNote := database.Note{
|
||||
UserID: user.ID,
|
||||
BookUUID: b1.UUID,
|
||||
Body: "publicNote content",
|
||||
Public: true,
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&publicNote), "preparing publicNote")
|
||||
deletedNote := database.Note{
|
||||
UserID: user.ID,
|
||||
BookUUID: b1.UUID,
|
||||
Deleted: true,
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&deletedNote), "preparing deletedNote")
|
||||
|
||||
getURL := func(noteUUID string) string {
|
||||
return fmt.Sprintf("/api/v3/notes/%s", noteUUID)
|
||||
}
|
||||
|
||||
t.Run("owner accessing private note", func(t *testing.T) {
|
||||
// Execute
|
||||
url := getURL(publicNote.UUID)
|
||||
req := testutils.MakeReq(server.URL, "GET", url, "")
|
||||
res := testutils.HTTPAuthDo(t, req, user)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusOK, "")
|
||||
|
||||
var payload presenters.Note
|
||||
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
|
||||
t.Fatal(errors.Wrap(err, "decoding payload"))
|
||||
}
|
||||
|
||||
var n2Record database.Note
|
||||
testutils.MustExec(t, testutils.DB.Where("uuid = ?", publicNote.UUID).First(&n2Record), "finding n2Record")
|
||||
|
||||
expected := getExpectedNotePayload(n2Record, b1, user)
|
||||
assert.DeepEqual(t, payload, expected, "payload mismatch")
|
||||
})
|
||||
|
||||
t.Run("owner accessing public note", func(t *testing.T) {
|
||||
// Execute
|
||||
url := getURL(publicNote.UUID)
|
||||
req := testutils.MakeReq(server.URL, "GET", url, "")
|
||||
res := testutils.HTTPAuthDo(t, req, user)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusOK, "")
|
||||
|
||||
var payload presenters.Note
|
||||
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
|
||||
t.Fatal(errors.Wrap(err, "decoding payload"))
|
||||
}
|
||||
|
||||
var n2Record database.Note
|
||||
testutils.MustExec(t, testutils.DB.Where("uuid = ?", publicNote.UUID).First(&n2Record), "finding n2Record")
|
||||
|
||||
expected := getExpectedNotePayload(n2Record, b1, user)
|
||||
assert.DeepEqual(t, payload, expected, "payload mismatch")
|
||||
})
|
||||
|
||||
t.Run("non-owner accessing public note", func(t *testing.T) {
|
||||
// Execute
|
||||
url := getURL(publicNote.UUID)
|
||||
req := testutils.MakeReq(server.URL, "GET", url, "")
|
||||
res := testutils.HTTPAuthDo(t, req, anotherUser)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusOK, "")
|
||||
|
||||
var payload presenters.Note
|
||||
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
|
||||
t.Fatal(errors.Wrap(err, "decoding payload"))
|
||||
}
|
||||
|
||||
var n2Record database.Note
|
||||
testutils.MustExec(t, testutils.DB.Where("uuid = ?", publicNote.UUID).First(&n2Record), "finding n2Record")
|
||||
|
||||
expected := getExpectedNotePayload(n2Record, b1, user)
|
||||
assert.DeepEqual(t, payload, expected, "payload mismatch")
|
||||
})
|
||||
|
||||
t.Run("non-owner accessing private note", func(t *testing.T) {
|
||||
// Execute
|
||||
url := getURL(privateNote.UUID)
|
||||
req := testutils.MakeReq(server.URL, "GET", url, "")
|
||||
res := testutils.HTTPAuthDo(t, req, anotherUser)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusNotFound, "")
|
||||
|
||||
body, err := ioutil.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
t.Fatal(errors.Wrap(err, "reading body"))
|
||||
}
|
||||
|
||||
assert.DeepEqual(t, string(body), "not found\n", "payload mismatch")
|
||||
})
|
||||
|
||||
t.Run("guest accessing public note", func(t *testing.T) {
|
||||
// Execute
|
||||
url := getURL(publicNote.UUID)
|
||||
req := testutils.MakeReq(server.URL, "GET", url, "")
|
||||
res := testutils.HTTPDo(t, req)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusOK, "")
|
||||
|
||||
var payload presenters.Note
|
||||
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
|
||||
t.Fatal(errors.Wrap(err, "decoding payload"))
|
||||
}
|
||||
|
||||
var n2Record database.Note
|
||||
testutils.MustExec(t, testutils.DB.Where("uuid = ?", publicNote.UUID).First(&n2Record), "finding n2Record")
|
||||
|
||||
expected := getExpectedNotePayload(n2Record, b1, user)
|
||||
assert.DeepEqual(t, payload, expected, "payload mismatch")
|
||||
})
|
||||
|
||||
t.Run("guest accessing private note", func(t *testing.T) {
|
||||
// Execute
|
||||
url := getURL(privateNote.UUID)
|
||||
req := testutils.MakeReq(server.URL, "GET", url, "")
|
||||
res := testutils.HTTPDo(t, req)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusNotFound, "")
|
||||
|
||||
body, err := ioutil.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
t.Fatal(errors.Wrap(err, "reading body"))
|
||||
}
|
||||
|
||||
assert.DeepEqual(t, string(body), "not found\n", "payload mismatch")
|
||||
})
|
||||
|
||||
t.Run("nonexistent", func(t *testing.T) {
|
||||
// Execute
|
||||
url := getURL("somerandomstring")
|
||||
req := testutils.MakeReq(server.URL, "GET", url, "")
|
||||
res := testutils.HTTPAuthDo(t, req, user)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusNotFound, "")
|
||||
|
||||
body, err := ioutil.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
t.Fatal(errors.Wrap(err, "reading body"))
|
||||
}
|
||||
|
||||
assert.DeepEqual(t, string(body), "not found\n", "payload mismatch")
|
||||
})
|
||||
|
||||
t.Run("deleted", func(t *testing.T) {
|
||||
// Execute
|
||||
url := getURL(deletedNote.UUID)
|
||||
req := testutils.MakeReq(server.URL, "GET", url, "")
|
||||
res := testutils.HTTPAuthDo(t, req, user)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusNotFound, "")
|
||||
|
||||
body, err := ioutil.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
t.Fatal(errors.Wrap(err, "reading body"))
|
||||
}
|
||||
|
||||
assert.DeepEqual(t, string(body), "not found\n", "payload mismatch")
|
||||
})
|
||||
}
|
||||
|
||||
func TestCreateNote(t *testing.T) {
|
||||
defer testutils.ClearData(testutils.DB)
|
||||
|
||||
// Setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
Clock: clock.NewMock(),
|
||||
Config: config.Config{
|
||||
PageTemplateDir: "../views",
|
||||
},
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
user := testutils.SetupUserData()
|
||||
testutils.SetupAccountData(user, "alice@test.com", "pass1234")
|
||||
testutils.MustExec(t, testutils.DB.Model(&user).Update("max_usn", 101), "preparing user max_usn")
|
||||
|
||||
b1 := database.Book{
|
||||
UserID: user.ID,
|
||||
Label: "js",
|
||||
USN: 58,
|
||||
}
|
||||
testutils.MustExec(t, testutils.DB.Save(&b1), "preparing b1")
|
||||
|
||||
// Execute
|
||||
|
||||
dat := fmt.Sprintf(`{"book_uuid": "%s", "content": "note content"}`, b1.UUID)
|
||||
req := testutils.MakeReq(server.URL, "POST", "/api/v3/notes", dat)
|
||||
res := testutils.HTTPAuthDo(t, req, user)
|
||||
|
||||
// Test
|
||||
assert.StatusCodeEquals(t, res, http.StatusCreated, "")
|
||||
|
||||
var noteRecord database.Note
|
||||
var bookRecord database.Book
|
||||
var userRecord database.User
|
||||
var bookCount, noteCount int
|
||||
testutils.MustExec(t, testutils.DB.Model(&database.Book{}).Count(&bookCount), "counting books")
|
||||
testutils.MustExec(t, testutils.DB.Model(&database.Note{}).Count(¬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")
|
||||
})
|
||||
}
|
||||
}
|
||||
124
pkg/server/controllers/routes.go
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
package controllers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/dnote/dnote/pkg/server/app"
|
||||
mw "github.com/dnote/dnote/pkg/server/middleware"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// Route represents a single route
|
||||
type Route struct {
|
||||
Method string
|
||||
Pattern string
|
||||
Handler http.HandlerFunc
|
||||
RateLimit bool
|
||||
}
|
||||
|
||||
// RouteConfig is the configuration for routes
|
||||
type RouteConfig struct {
|
||||
Controllers *Controllers
|
||||
WebRoutes []Route
|
||||
APIRoutes []Route
|
||||
}
|
||||
|
||||
// NewWebRoutes returns a new web routes
|
||||
func NewWebRoutes(a *app.App, c *Controllers) []Route {
|
||||
redirectGuest := &mw.AuthParams{RedirectGuestsToLogin: true}
|
||||
|
||||
ret := []Route{
|
||||
{"GET", "/", mw.Auth(a, c.Users.Settings, redirectGuest), true},
|
||||
{"GET", "/about", mw.Auth(a, c.Users.About, redirectGuest), true},
|
||||
{"GET", "/login", mw.GuestOnly(a, c.Users.NewLogin), true},
|
||||
{"POST", "/login", mw.GuestOnly(a, c.Users.Login), true},
|
||||
{"POST", "/logout", c.Users.Logout, true},
|
||||
|
||||
{"GET", "/password-reset", c.Users.PasswordResetView.ServeHTTP, true},
|
||||
{"PATCH", "/password-reset", c.Users.PasswordReset, true},
|
||||
{"GET", "/password-reset/{token}", c.Users.PasswordResetConfirm, true},
|
||||
{"POST", "/reset-token", c.Users.CreateResetToken, true},
|
||||
{"POST", "/verification-token", mw.Auth(a, c.Users.CreateEmailVerificationToken, redirectGuest), true},
|
||||
{"GET", "/verify-email/{token}", mw.Auth(a, c.Users.VerifyEmail, redirectGuest), true},
|
||||
{"PATCH", "/account/profile", mw.Auth(a, c.Users.ProfileUpdate, nil), true},
|
||||
{"PATCH", "/account/password", mw.Auth(a, c.Users.PasswordUpdate, nil), true},
|
||||
}
|
||||
|
||||
if !a.Config.DisableRegistration {
|
||||
ret = append(ret, Route{"GET", "/join", c.Users.New, true})
|
||||
ret = append(ret, Route{"POST", "/join", c.Users.Create, true})
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
// NewAPIRoutes returns a new api routes
|
||||
func NewAPIRoutes(a *app.App, c *Controllers) []Route {
|
||||
|
||||
proOnly := mw.AuthParams{ProOnly: true}
|
||||
|
||||
return []Route{
|
||||
// internal
|
||||
{"GET", "/health", c.Health.Index, true},
|
||||
|
||||
// v3
|
||||
{"GET", "/v3/sync/fragment", mw.Cors(mw.Auth(a, c.Sync.GetSyncFragment, &proOnly)), false},
|
||||
{"GET", "/v3/sync/state", mw.Cors(mw.Auth(a, c.Sync.GetSyncState, &proOnly)), false},
|
||||
{"POST", "/v3/signin", mw.Cors(c.Users.V3Login), true},
|
||||
{"POST", "/v3/signout", mw.Cors(c.Users.V3Logout), true},
|
||||
{"OPTIONS", "/v3/signout", mw.Cors(c.Users.logoutOptions), true},
|
||||
{"GET", "/v3/notes", mw.Cors(mw.Auth(a, c.Notes.V3Index, nil)), true},
|
||||
{"GET", "/v3/notes/{noteUUID}", c.Notes.V3Show, true},
|
||||
{"POST", "/v3/notes", mw.Cors(mw.Auth(a, c.Notes.V3Create, nil)), true},
|
||||
{"DELETE", "/v3/notes/{noteUUID}", mw.Cors(mw.Auth(a, c.Notes.V3Delete, nil)), true},
|
||||
{"PATCH", "/v3/notes/{noteUUID}", mw.Cors(mw.Auth(a, c.Notes.V3Update, nil)), true},
|
||||
{"OPTIONS", "/v3/notes", mw.Cors(c.Notes.IndexOptions), true},
|
||||
{"GET", "/v3/books", mw.Cors(mw.Auth(a, c.Books.V3Index, nil)), true},
|
||||
{"GET", "/v3/books/{bookUUID}", mw.Cors(mw.Auth(a, c.Books.V3Show, nil)), true},
|
||||
{"POST", "/v3/books", mw.Cors(mw.Auth(a, c.Books.V3Create, nil)), true},
|
||||
{"PATCH", "/v3/books/{bookUUID}", mw.Cors(mw.Auth(a, c.Books.V3Update, nil)), true},
|
||||
{"DELETE", "/v3/books/{bookUUID}", mw.Cors(mw.Auth(a, c.Books.V3Delete, nil)), true},
|
||||
{"OPTIONS", "/v3/books", mw.Cors(c.Books.IndexOptions), true},
|
||||
}
|
||||
}
|
||||
|
||||
func registerRoutes(router *mux.Router, wrapper mw.Middleware, app *app.App, routes []Route) {
|
||||
for _, route := range routes {
|
||||
wrappedHandler := wrapper(route.Handler, app, route.RateLimit)
|
||||
|
||||
router.
|
||||
Handle(route.Pattern, wrappedHandler).
|
||||
Methods(route.Method)
|
||||
}
|
||||
}
|
||||
|
||||
// NewRouter creates and returns a new router
|
||||
func NewRouter(app *app.App, rc RouteConfig) (http.Handler, error) {
|
||||
if err := app.Validate(); err != nil {
|
||||
return nil, errors.Wrap(err, "validating the app parameters")
|
||||
}
|
||||
|
||||
router := mux.NewRouter().StrictSlash(true)
|
||||
|
||||
webRouter := router.PathPrefix("/").Subrouter()
|
||||
apiRouter := router.PathPrefix("/api").Subrouter()
|
||||
registerRoutes(webRouter, mw.WebMw, app, rc.WebRoutes)
|
||||
registerRoutes(apiRouter, mw.APIMw, app, rc.APIRoutes)
|
||||
|
||||
router.PathPrefix("/api/v1").Handler(mw.ApplyLimit(mw.NotSupported, true))
|
||||
router.PathPrefix("/api/v2").Handler(mw.ApplyLimit(mw.NotSupported, true))
|
||||
|
||||
// static
|
||||
staticHandler := http.StripPrefix("/static/", http.FileServer(http.Dir(app.Config.StaticDir)))
|
||||
router.PathPrefix("/static/").Handler(staticHandler)
|
||||
|
||||
router.HandleFunc("/robots.txt", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("User-agent: *\nAllow: /"))
|
||||
})
|
||||
|
||||
// catch-all
|
||||
router.PathPrefix("/").HandlerFunc(rc.Controllers.Static.NotFound)
|
||||
|
||||
return mw.Global(router), nil
|
||||
}
|
||||
77
pkg/server/controllers/routes_test.go
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
/* Copyright (C) 2019, 2020 Monomax Software Pty Ltd
|
||||
*
|
||||
* This file is part of Dnote.
|
||||
*
|
||||
* Dnote is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Dnote is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/dnote/dnote/pkg/assert"
|
||||
"github.com/dnote/dnote/pkg/clock"
|
||||
"github.com/dnote/dnote/pkg/server/app"
|
||||
"github.com/dnote/dnote/pkg/server/config"
|
||||
"github.com/dnote/dnote/pkg/server/testutils"
|
||||
)
|
||||
|
||||
func TestNotSupportedVersions(t *testing.T) {
|
||||
testCases := []struct {
|
||||
path string
|
||||
}{
|
||||
// v1
|
||||
{
|
||||
path: "/api/v1",
|
||||
},
|
||||
{
|
||||
path: "/api/v1/foo",
|
||||
},
|
||||
{
|
||||
path: "/api/v1/bar/baz",
|
||||
},
|
||||
// v2
|
||||
{
|
||||
path: "/api/v2",
|
||||
},
|
||||
{
|
||||
path: "/api/v2/foo",
|
||||
},
|
||||
{
|
||||
path: "/api/v2/bar/baz",
|
||||
},
|
||||
}
|
||||
|
||||
// setup
|
||||
server := MustNewServer(t, &app.App{
|
||||
Clock: clock.NewMock(),
|
||||
Config: config.Config{
|
||||
PageTemplateDir: "../views",
|
||||
},
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.path, func(t *testing.T) {
|
||||
// execute
|
||||
req := testutils.MakeReq(server.URL, "GET", tc.path, "")
|
||||
res := testutils.HTTPDo(t, req)
|
||||
|
||||
// test
|
||||
assert.Equal(t, res.StatusCode, http.StatusGone, "status code mismatch")
|
||||
})
|
||||
}
|
||||
}
|
||||
35
pkg/server/controllers/static.go
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
package controllers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/dnote/dnote/pkg/server/app"
|
||||
"github.com/dnote/dnote/pkg/server/views"
|
||||
)
|
||||
|
||||
// NewStatic creates a new Static controller.
|
||||
func NewStatic(app *app.App, baseDir string) *Static {
|
||||
return &Static{
|
||||
NotFoundView: views.NewView(baseDir, app, views.Config{Title: "Not Found", Layout: "base"}, "static/not_found"),
|
||||
}
|
||||
}
|
||||
|
||||
// Static is a static controller
|
||||
type Static struct {
|
||||
NotFoundView *views.View
|
||||
}
|
||||
|
||||
// NotFound is a catch-all handler for requests with no matching handler
|
||||
func (s *Static) NotFound(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
|
||||
accept := r.Header.Get("Accept")
|
||||
|
||||
if strings.Contains(accept, "text/html") {
|
||||
s.NotFoundView.Render(w, r, nil, http.StatusOK)
|
||||
} else {
|
||||
statusText := http.StatusText(http.StatusNotFound)
|
||||
w.Write([]byte(statusText))
|
||||
}
|
||||
}
|
||||
|
|
@ -16,7 +16,7 @@
|
|||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package api
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
|
@ -26,13 +26,27 @@ import (
|
|||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/dnote/dnote/pkg/server/context"
|
||||
"github.com/dnote/dnote/pkg/server/database"
|
||||
"github.com/dnote/dnote/pkg/server/handlers"
|
||||
"github.com/dnote/dnote/pkg/server/helpers"
|
||||
"github.com/dnote/dnote/pkg/server/log"
|
||||
"github.com/dnote/dnote/pkg/server/middleware"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/dnote/dnote/pkg/server/app"
|
||||
)
|
||||
|
||||
// NewSync creates a new Sync controller
|
||||
func NewSync(app *app.App) *Sync {
|
||||
return &Sync{
|
||||
app: app,
|
||||
}
|
||||
}
|
||||
|
||||
// Sync is a sync controller.
|
||||
type Sync struct {
|
||||
app *app.App
|
||||
}
|
||||
|
||||
// fullSyncBefore is the system-wide timestamp that represents the point in time
|
||||
// before which clients must perform a full-sync rather than incremental sync.
|
||||
const fullSyncBefore = 0
|
||||
|
|
@ -121,13 +135,13 @@ func (e *queryParamError) Error() string {
|
|||
return fmt.Sprintf("invalid query param %s=%s. %s", e.key, e.value, e.message)
|
||||
}
|
||||
|
||||
func (a *API) newFragment(userID, userMaxUSN, afterUSN, limit int) (SyncFragment, error) {
|
||||
func (s *Sync) newFragment(userID, userMaxUSN, afterUSN, limit int) (SyncFragment, error) {
|
||||
var notes []database.Note
|
||||
if err := a.App.DB.Where("user_id = ? AND usn > ? AND usn <= ?", userID, afterUSN, userMaxUSN).Order("usn ASC").Limit(limit).Find(¬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)
|
||||
}
|
||||
|
|
@ -16,7 +16,7 @@
|
|||
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package api
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||