From fd615102a841db27a182cfbf2bfc7e7019211838 Mon Sep 17 00:00:00 2001 From: Daniel Mason Date: Fri, 2 Apr 2021 01:56:08 +1300 Subject: [PATCH] 0.0.10 - Fixed looking up invalid profiles - Added valid error handling to bad request && rate limiting - Add Sendgrid library (Will add SMTP later) - Complete password reset process --- .env.example | 6 + .gitlab-ci.yml | 2 +- cmd/go-scrobble/main.go | 3 + docs/changelog.md | 6 + docs/config.md | 6 + go.mod | 5 +- go.sum | 25 +- internal/goscrobble/external_lastfm.go | 4 - internal/goscrobble/ingress_jellyfin.go | 3 + internal/goscrobble/ingress_multiscrobbler.go | 66 ++++ internal/goscrobble/server.go | 315 ++++++------------ internal/goscrobble/server_middleware.go | 104 ++++++ internal/goscrobble/server_responses.go | 58 ++++ internal/goscrobble/server_static.go | 43 +++ internal/goscrobble/smtp.go | 18 + internal/goscrobble/timers.go | 14 + internal/goscrobble/user.go | 92 ++++- migrations/7_resettoken.down.sql | 1 + migrations/7_resettoken.up.sql | 6 + web/src/Api/index.js | 148 ++++++-- web/src/App.js | 4 + web/src/Contexts/AuthContextProvider.js | 8 +- web/src/Pages/Admin.js | 8 +- web/src/Pages/Dashboard.js | 2 +- web/src/Pages/Login.js | 13 +- web/src/Pages/Profile.js | 4 +- web/src/Pages/Reset.css | 15 + web/src/Pages/Reset.js | 153 +++++++++ 28 files changed, 871 insertions(+), 261 deletions(-) delete mode 100644 internal/goscrobble/external_lastfm.go create mode 100644 internal/goscrobble/ingress_multiscrobbler.go create mode 100644 internal/goscrobble/server_middleware.go create mode 100644 internal/goscrobble/server_responses.go create mode 100644 internal/goscrobble/server_static.go create mode 100644 internal/goscrobble/smtp.go create mode 100644 internal/goscrobble/timers.go create mode 100644 migrations/7_resettoken.down.sql create mode 100644 migrations/7_resettoken.up.sql create mode 100644 web/src/Pages/Reset.css create mode 100644 web/src/Pages/Reset.js diff --git a/.env.example b/.env.example index ca0c2360..9c662394 100644 --- a/.env.example +++ b/.env.example @@ -16,3 +16,9 @@ JWT_EXPIRY=86400 REVERSE_PROXIES=127.0.0.1 PORT=42069 + +SENDGRID_API_KEY= +MAIL_FROM_ADDRESS= +MAIL_FROM_NAME= + +GOSCROBBLE_DOMAIN="" diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 083d9d24..b750fe23 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -3,7 +3,7 @@ stages: - bundle variables: - VERSION: 0.0.9 + VERSION: 0.0.10 build-go: image: golang:1.16.2 diff --git a/cmd/go-scrobble/main.go b/cmd/go-scrobble/main.go index a17f3291..381c506c 100644 --- a/cmd/go-scrobble/main.go +++ b/cmd/go-scrobble/main.go @@ -51,6 +51,9 @@ func main() { goscrobble.InitRedis() defer goscrobble.CloseRedisConn() + // Clear old reset tokens regularly + // go goscrobble.ClearTokenTimer() + // Boot up API webserver \o/ goscrobble.HandleRequests(port) } diff --git a/docs/changelog.md b/docs/changelog.md index 8441ca50..7fa35a13 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,3 +1,9 @@ +# 0.0.10 +- Fixed looking up invalid profiles +- Added valid error handling to bad request && rate limiting +- Add Sendgrid library (Will add SMTP later) +- Complete password reset process + # 0.0.9 - Fix mobile menu auto collapse on select - Add /u/ route for public user profiles (Added private flag to db - to implement later) diff --git a/docs/config.md b/docs/config.md index f74f2eca..f7b42548 100644 --- a/docs/config.md +++ b/docs/config.md @@ -24,3 +24,9 @@ These are stored in `web/.env.production` and `web/.env.development` REVERSE_PROXIES=127.0.0.1 // Comma separated list of servers to ignore for IP logs PORT=42069 // Server port + + SENDGRID_API_KEY= // API KEY + MAIL_FROM_ADDRESS= // FROM email + MAIL_FROM_NAME= // FROM name + + GOSCROBBLE_DOMAIN="" // Full domain for email links (https://goscrobble.com)) diff --git a/go.mod b/go.mod index 356355e9..7ed44571 100644 --- a/go.mod +++ b/go.mod @@ -10,18 +10,19 @@ require ( github.com/docker/docker v17.12.0-ce-rc1.0.20200618181300-9dc6525e6118+incompatible // indirect github.com/docker/go-connections v0.4.0 // indirect github.com/docker/go-units v0.4.0 // indirect - github.com/go-redis/redis/v8 v8.8.0 // indirect + github.com/go-redis/redis/v8 v8.8.0 github.com/go-sql-driver/mysql v1.5.0 github.com/gogo/protobuf v1.3.1 // indirect github.com/golang-migrate/migrate v3.5.4+incompatible github.com/golang/protobuf v1.4.3 // indirect github.com/gorilla/mux v1.8.0 github.com/joho/godotenv v1.3.0 - github.com/mitchellh/mapstructure v1.4.1 github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.0.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/rs/cors v1.7.0 + github.com/sendgrid/rest v2.6.3+incompatible // indirect + github.com/sendgrid/sendgrid-go v3.8.0+incompatible github.com/sirupsen/logrus v1.7.0 // indirect golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba diff --git a/go.sum b/go.sum index fd1e7837..e97573db 100644 --- a/go.sum +++ b/go.sum @@ -10,6 +10,7 @@ github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGX github.com/containerd/containerd v1.4.1 h1:pASeJT3R3YyVn+94qEPk0SnU1OQ20Jd/T+SPKy9xehY= github.com/containerd/containerd v1.4.1/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA= 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/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= @@ -28,6 +29,7 @@ github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.m github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/go-redis/redis/v8 v8.8.0 h1:fDZP58UN/1RD3DjtTXP/fFZ04TFohSYhjZDkcDe2dnw= github.com/go-redis/redis/v8 v8.8.0/go.mod h1:F7resOH5Kdug49Otu24RjHWwgK7u9AmtqWMnCV1iP5Y= @@ -56,6 +58,7 @@ github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= @@ -66,14 +69,15 @@ github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqx github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag= -github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/nxadm/tail v1.4.4 h1:DQuhQpB1tVlglWS2hLQ5OV6B5r8aGxSrPc5Qo6uTN78= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.15.0 h1:1V1NfVQR87RtWAgp1lv9JZJ5Jap+XFGKPi00andXGi4= github.com/onsi/ginkgo v1.15.0/go.mod h1:hF8qUzuuC8DJGygJH3726JnCZX4MYbRB8yFfISqnKUg= github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/onsi/gomega v1.10.5 h1:7n6FEkpFmfCoo2t+YYqXH0evK+a9ICQz0xcAy9dYcaQ= github.com/onsi/gomega v1.10.5/go.mod h1:gza4q3jKQJijlu05nKWRCW/GavJumGt8aNRxWg7mt48= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= @@ -82,22 +86,29 @@ github.com/opencontainers/image-spec v1.0.1/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zM github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/rs/cors v1.7.0 h1:+88SsELBHx5r+hZ8TCkggzSstaWNbDvThkVK8H6f9ik= github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU= +github.com/sendgrid/rest v2.6.3+incompatible h1:h/uruXAzKxVyDDIQX/MkQI73p/gsdpEnb5q2wxSvTsA= +github.com/sendgrid/rest v2.6.3+incompatible/go.mod h1:kXX7q3jZtJXK5c5qK83bSGMdV6tsOE70KbHoqJls4lE= +github.com/sendgrid/sendgrid-go v3.8.0+incompatible h1:7yoUFMwT+jDI2ArBpC6zvtuQj1RUyYfCDl7zZea3XV4= +github.com/sendgrid/sendgrid-go v3.8.0+incompatible/go.mod h1:QRQt+LX/NmgVEvmdRw0VT/QgUn499+iza2FnDca9fg8= github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= github.com/sirupsen/logrus v1.7.0 h1:ShrD1U9pZB12TX0cVy0DtePoCH97K8EtX+mg7ZARUtM= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= go.opentelemetry.io/otel v0.19.0 h1:Lenfy7QHRXPZVsw/12CWpxX6d/JkrX8wrx2vO8G80Ng= go.opentelemetry.io/otel v0.19.0/go.mod h1:j9bF567N9EfomkSidSfmMwIwIBuP37AMAIzVW85OxSg= go.opentelemetry.io/otel/metric v0.19.0 h1:dtZ1Ju44gkJkYvo+3qGqVXmf88tc+a42edOywypengg= go.opentelemetry.io/otel/metric v0.19.0/go.mod h1:8f9fglJPRnXuskQmKpnad31lcLJ2VmNNqIsx/uIwBSc= +go.opentelemetry.io/otel/oteltest v0.19.0 h1:YVfA0ByROYqTwOxqHVZYZExzEpfZor+MU1rU+ip2v9Q= go.opentelemetry.io/otel/oteltest v0.19.0/go.mod h1:tI4yxwh8U21v7JD6R3BcA/2+RBoTKFexE/PJ/nSO7IA= go.opentelemetry.io/otel/trace v0.19.0 h1:1ucYlenXIDA1OlHVLDZKX0ObXV5RLaq06DtUKz5e5zc= go.opentelemetry.io/otel/trace v0.19.0/go.mod h1:4IXiNextNOpPnRlI4ryK69mn5iC84bjBWZQA5DXz/qg= @@ -119,8 +130,7 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201029221708-28c70e62bb1d h1:dOiJ2n2cMwGLce/74I/QHMbnpk5GfY7InR8rczoMqRM= -golang.org/x/net v0.0.0-20201029221708-28c70e62bb1d/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb h1:eBmm0M9fYhWpKZLjQUUKka/LtIxf46G4fxeEz5KJr9U= golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -139,10 +149,10 @@ golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201029080932-201ba4db2418 h1:HlFl4V6pEMziuLXyRkm5BIYq1y1GAbb02pRlWvI54OM= -golang.org/x/sys v0.0.0-20201029080932-201ba4db2418/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210112080510-489259a85091 h1:DMyOG0U+gKfu8JZzg2UQe9MeaC1X+xQWlAKcRnjxjCw= golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba h1:O8mE0/t419eoIwhTFpKVkHiTs/Igowgfkj25AcZrtiE= golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -184,9 +194,12 @@ google.golang.org/protobuf v1.25.0 h1:Ejskq+SyPohKW+1uil0JJMtmHCgJPJ/qWTxr8qp+R4 google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/internal/goscrobble/external_lastfm.go b/internal/goscrobble/external_lastfm.go deleted file mode 100644 index 5c810364..00000000 --- a/internal/goscrobble/external_lastfm.go +++ /dev/null @@ -1,4 +0,0 @@ -package goscrobble - -func getImageLastFM(src string) { -} diff --git a/internal/goscrobble/ingress_jellyfin.go b/internal/goscrobble/ingress_jellyfin.go index 4add3b6b..002c035d 100644 --- a/internal/goscrobble/ingress_jellyfin.go +++ b/internal/goscrobble/ingress_jellyfin.go @@ -10,6 +10,9 @@ import ( // ParseJellyfinInput - Transform API data into a common struct func ParseJellyfinInput(userUUID string, data map[string]interface{}, ip net.IP, tx *sql.Tx) error { + // Debugging + fmt.Printf("%+v", data) + if data["ItemType"] != "Audio" { return errors.New("Media type not audio") } diff --git a/internal/goscrobble/ingress_multiscrobbler.go b/internal/goscrobble/ingress_multiscrobbler.go new file mode 100644 index 00000000..0c12ac26 --- /dev/null +++ b/internal/goscrobble/ingress_multiscrobbler.go @@ -0,0 +1,66 @@ +package goscrobble + +import ( + "database/sql" + "fmt" + "net" +) + +// ParseMultiScrobblerInput - Transform API data +func ParseMultiScrobblerInput(userUUID string, data map[string]interface{}, ip net.IP, tx *sql.Tx) error { + // Debugging + fmt.Printf("%+v", data) + + // if data["ItemType"] != "Audio" { + // return errors.New("Media type not audio") + // } + + // // Safety Checks + // if data["Artist"] == nil { + // return errors.New("Missing artist data") + // } + + // if data["Album"] == nil { + // return errors.New("Missing album data") + // } + + // if data["Name"] == nil { + // return errors.New("Missing track data") + // } + + // // Insert artist if not exist + // artist, err := insertArtist(fmt.Sprintf("%s", data["Artist"]), fmt.Sprintf("%s", data["Provider_musicbrainzartist"]), tx) + // if err != nil { + // log.Printf("%+v", err) + // return errors.New("Failed to map artist") + // } + + // // Insert album if not exist + // artists := []string{artist.Uuid} + // album, err := insertAlbum(fmt.Sprintf("%s", data["Album"]), fmt.Sprintf("%s", data["Provider_musicbrainzalbum"]), artists, tx) + // if err != nil { + // log.Printf("%+v", err) + // return errors.New("Failed to map album") + // } + + // // Insert album if not exist + // track, err := insertTrack(fmt.Sprintf("%s", data["Name"]), fmt.Sprintf("%s", data["Provider_musicbrainztrack"]), album.Uuid, artists, tx) + // if err != nil { + // log.Printf("%+v", err) + // return errors.New("Failed to map track") + // } + + // // Insert album if not exist + // err = insertScrobble(userUUID, track.Uuid, "jellyfin", ip, tx) + // if err != nil { + // log.Printf("%+v", err) + // return errors.New("Failed to map track") + // } + + // _ = album + // _ = artist + // _ = track + + // Insert track if not exist + return nil +} diff --git a/internal/goscrobble/server.go b/internal/goscrobble/server.go index 25e39c26..791a0439 100644 --- a/internal/goscrobble/server.go +++ b/internal/goscrobble/server.go @@ -2,38 +2,21 @@ package goscrobble import ( "encoding/json" - "errors" "fmt" "log" "net/http" - "os" - "path/filepath" "strings" "github.com/gorilla/mux" "github.com/rs/cors" ) -// spaHandler - Handles Single Page Applications (React) -type spaHandler struct { - staticPath string - indexPath string -} - type jsonResponse struct { - Err string `json:"error,omitempty"` - Msg string `json:"message,omitempty"` + Err string `json:"error,omitempty"` + Msg string `json:"message,omitempty"` + Valid bool `json:"valid,omitempty"` } -// Limits to 1 req / 4 sec -var heavyLimiter = NewIPRateLimiter(0.25, 2) - -// Limits to 5 req / sec -var standardLimiter = NewIPRateLimiter(5, 5) - -// Limits to 10 req / sec -var lightLimiter = NewIPRateLimiter(10, 10) - // List of Reverse proxies var ReverseProxies []string @@ -50,6 +33,7 @@ func HandleRequests(port string) { // Static Token for /ingress v1.HandleFunc("/ingress/jellyfin", tokenMiddleware(handleIngress)).Methods("POST") + v1.HandleFunc("/ingress/multiscrobbler", tokenMiddleware(handleIngress)).Methods("POST") // JWT Auth - PWN PROFILE ONLY. v1.HandleFunc("/user", jwtMiddleware(fetchUser)).Methods("GET") @@ -66,6 +50,8 @@ func HandleRequests(port string) { v1.HandleFunc("/register", limitMiddleware(handleRegister, heavyLimiter)).Methods("POST") v1.HandleFunc("/login", limitMiddleware(handleLogin, standardLimiter)).Methods("POST") + v1.HandleFunc("/sendreset", limitMiddleware(handleSendReset, heavyLimiter)).Methods("POST") + v1.HandleFunc("/resetpassword", limitMiddleware(handleResetPassword, heavyLimiter)).Methods("POST") // This just prevents it serving frontend stuff over /api r.PathPrefix("/api") @@ -87,161 +73,7 @@ func HandleRequests(port string) { log.Fatal(http.ListenAndServe(":"+port, handler)) } -// MIDDLEWARE RESPONSES -// throwUnauthorized - Throws a 403 -func throwUnauthorized(w http.ResponseWriter, m string) { - jr := jsonResponse{ - Err: m, - } - js, _ := json.Marshal(&jr) - err := errors.New(string(js)) - http.Error(w, err.Error(), http.StatusUnauthorized) -} - -// throwUnauthorized - Throws a 403 -func throwBadReq(w http.ResponseWriter, m string) { - jr := jsonResponse{ - Err: m, - } - js, _ := json.Marshal(&jr) - err := errors.New(string(js)) - http.Error(w, err.Error(), http.StatusBadRequest) -} - -// throwOkError - Throws a 403 -func throwOkError(w http.ResponseWriter, m string) { - jr := jsonResponse{ - Err: m, - } - js, _ := json.Marshal(&jr) - w.WriteHeader(http.StatusOK) - w.Write(js) -} - -// throwOkMessage - Throws a happy 200 -func throwOkMessage(w http.ResponseWriter, m string) { - jr := jsonResponse{ - Msg: m, - } - js, _ := json.Marshal(&jr) - w.WriteHeader(http.StatusOK) - w.Write(js) -} - -// throwOkMessage - Throws a happy 200 -func throwInvalidJson(w http.ResponseWriter) { - jr := jsonResponse{ - Err: "Invalid JSON", - } - js, _ := json.Marshal(&jr) - w.WriteHeader(http.StatusBadRequest) - w.Write(js) -} - -// generateJsonMessage - Generates a message:str response -func generateJsonMessage(m string) []byte { - jr := jsonResponse{ - Msg: m, - } - js, _ := json.Marshal(&jr) - return js -} - -// generateJsonError - Generates a err:str response -func generateJsonError(m string) []byte { - jr := jsonResponse{ - Err: m, - } - js, _ := json.Marshal(&jr) - return js -} - -// MIDDLEWARE ACTIONS -// tokenMiddleware - Validates token to a user -func tokenMiddleware(next func(http.ResponseWriter, *http.Request, string)) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - fullToken := r.Header.Get("Authorization") - authToken := strings.Replace(fullToken, "Bearer ", "", 1) - if authToken == "" { - throwUnauthorized(w, "A token is required") - return - } - - userUuid, err := getUserUuidForToken(authToken) - if err != nil { - throwUnauthorized(w, err.Error()) - return - } - - next(w, r, userUuid) - } -} - -// jwtMiddleware - Validates middleware to a user -func jwtMiddleware(next func(http.ResponseWriter, *http.Request, string, string)) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - fullToken := r.Header.Get("Authorization") - authToken := strings.Replace(fullToken, "Bearer ", "", 1) - claims, err := verifyJWTToken(authToken) - if err != nil { - throwUnauthorized(w, "Invalid JWT Token") - return - } - - var reqUuid string - for k, v := range mux.Vars(r) { - if k == "uuid" { - reqUuid = v - } - } - - next(w, r, claims.Subject, reqUuid) - } -} - -// adminMiddleware - Validates user is admin -func adminMiddleware(next func(http.ResponseWriter, *http.Request, string)) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - fullToken := r.Header.Get("Authorization") - authToken := strings.Replace(fullToken, "Bearer ", "", 1) - claims, err := verifyJWTToken(authToken) - if err != nil { - throwUnauthorized(w, "Invalid JWT Token") - return - } - - user, err := getUser(claims.Subject) - if err != nil { - throwUnauthorized(w, err.Error()) - return - } - - if !user.Admin { - throwUnauthorized(w, "User is not admin") - return - } - - next(w, r, claims.Subject) - } -} - -// limitMiddleware - Rate limits important stuff -func limitMiddleware(next http.HandlerFunc, limiter *IPRateLimiter) http.HandlerFunc { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - limiter := limiter.GetLimiter(r.RemoteAddr) - if !limiter.Allow() { - msg := generateJsonMessage("Too many requests") - w.WriteHeader(http.StatusTooManyRequests) - w.Write(msg) - return - } - - next(w, r) - }) -} - // API ENDPOINT HANDLING - // handleRegister - Does as it says! func handleRegister(w http.ResponseWriter, r *http.Request) { regReq := RegisterRequest{} @@ -296,6 +128,86 @@ func handleStats(w http.ResponseWriter, r *http.Request) { w.Write(js) } +// handleSendReset - Does as it says! +func handleSendReset(w http.ResponseWriter, r *http.Request) { + req := RegisterRequest{} + decoder := json.NewDecoder(r.Body) + err := decoder.Decode(&req) + if err != nil { + throwBadReq(w, err.Error()) + return + } + + if req.Email == "" { + throwOkError(w, "Invalid Email") + return + } + + _ = getUserIp(r) + user, err := getUserByEmail(req.Email) + if err != nil { + throwOkError(w, err.Error()) + return + } + + ip := getUserIp(r) + err = user.sendResetEmail(ip) + if err != nil { + throwOkError(w, err.Error()) + return + } + + throwOkMessage(w, "Password reset email sent") +} + +// handleSendReset - Does as it says! +func handleResetPassword(w http.ResponseWriter, r *http.Request) { + bodyJson, err := decodeJson(r.Body) + if err != nil { + throwInvalidJson(w) + return + } + + if bodyJson["password"] == nil { + // validating + valid, err := checkResetToken(fmt.Sprintf("%s", bodyJson["token"])) + if err != nil { + throwOkError(w, err.Error()) + return + } + jr := jsonResponse{ + Valid: valid, + } + msg, _ := json.Marshal(&jr) + w.WriteHeader(http.StatusOK) + w.Write(msg) + return + } else { + // resetting + token := fmt.Sprintf("%s", bodyJson["token"]) + pw := fmt.Sprintf("%s", bodyJson["password"]) + if len(pw) < 8 { + throwOkError(w, "Password must be at least 8 characters") + return + } + + ip := getUserIp(r) + user, err := getUserByResetToken(token) + if err != nil { + throwOkError(w, err.Error()) + return + } + err = user.updatePassword(pw, ip) + if err != nil { + throwOkError(w, err.Error()) + return + } + + throwOkMessage(w, "Password updated successfully!") + return + } +} + // serveEndpoint - API stuffs func handleIngress(w http.ResponseWriter, r *http.Request, userUuid string) { bodyJson, err := decodeJson(r.Body) @@ -304,32 +216,39 @@ func handleIngress(w http.ResponseWriter, r *http.Request, userUuid string) { return } + ip := getUserIp(r) + tx, _ := db.Begin() + ingressType := strings.Replace(r.URL.Path, "/api/v1/ingress/", "", 1) switch ingressType { case "jellyfin": - tx, _ := db.Begin() - - ip := getUserIp(r) err := ParseJellyfinInput(userUuid, bodyJson, ip, tx) if err != nil { - // log.Printf("Error inserting track: %+v", err) tx.Rollback() throwOkError(w, err.Error()) return } - - err = tx.Commit() + case "multiscrobbler": + err := ParseMultiScrobblerInput(userUuid, bodyJson, ip, tx) if err != nil { + tx.Rollback() throwOkError(w, err.Error()) return } + default: + tx.Rollback() + throwBadReq(w, "Unknown ingress type") + } - throwOkMessage(w, "success") + err = tx.Commit() + if err != nil { + throwOkError(w, err.Error()) return } - throwBadReq(w, "Unknown ingress type") + throwOkMessage(w, "success") + return } // fetchUser - Return personal userprofile @@ -432,35 +351,3 @@ func fetchProfile(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) w.Write(json) } - -// FRONTEND HANDLING - -// ServerHTTP - Frontend server -func (h spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - // Get the absolute path to prevent directory traversal - path, err := filepath.Abs(r.URL.Path) - if err != nil { - // If we failed to get the absolute path respond with a 400 bad request and return - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - // prepend the path with the path to the static directory - path = filepath.Join(h.staticPath, path) - - // check whether a file exists at the given path - _, err = os.Stat(path) - if os.IsNotExist(err) { - // file does not exist, serve index.html - http.ServeFile(w, r, filepath.Join(h.staticPath, h.indexPath)) - return - } else if err != nil { - // if we got an error (that wasn't that the file doesn't exist) stating the - // file, return a 500 internal server error and stop - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - // otherwise, use http.FileServer to serve the static dir - http.FileServer(http.Dir(h.staticPath)).ServeHTTP(w, r) -} diff --git a/internal/goscrobble/server_middleware.go b/internal/goscrobble/server_middleware.go new file mode 100644 index 00000000..f283b66d --- /dev/null +++ b/internal/goscrobble/server_middleware.go @@ -0,0 +1,104 @@ +package goscrobble + +import ( + "encoding/json" + "net/http" + "strings" + + "github.com/gorilla/mux" +) + +// Limits to 1 req / 4 sec +var heavyLimiter = NewIPRateLimiter(0.25, 2) + +// Limits to 5 req / sec +var standardLimiter = NewIPRateLimiter(5, 5) + +// Limits to 10 req / sec +var lightLimiter = NewIPRateLimiter(10, 10) + +// tokenMiddleware - Validates token to a user +func tokenMiddleware(next func(http.ResponseWriter, *http.Request, string)) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + fullToken := r.Header.Get("Authorization") + authToken := strings.Replace(fullToken, "Bearer ", "", 1) + if authToken == "" { + throwUnauthorized(w, "A token is required") + return + } + + userUuid, err := getUserUuidForToken(authToken) + if err != nil { + throwUnauthorized(w, err.Error()) + return + } + + next(w, r, userUuid) + } +} + +// jwtMiddleware - Validates middleware to a user +func jwtMiddleware(next func(http.ResponseWriter, *http.Request, string, string)) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + fullToken := r.Header.Get("Authorization") + authToken := strings.Replace(fullToken, "Bearer ", "", 1) + claims, err := verifyJWTToken(authToken) + if err != nil { + throwUnauthorized(w, "Invalid JWT Token") + return + } + + var reqUuid string + for k, v := range mux.Vars(r) { + if k == "uuid" { + reqUuid = v + } + } + + next(w, r, claims.Subject, reqUuid) + } +} + +// adminMiddleware - Validates user is admin +func adminMiddleware(next func(http.ResponseWriter, *http.Request, string)) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + fullToken := r.Header.Get("Authorization") + authToken := strings.Replace(fullToken, "Bearer ", "", 1) + claims, err := verifyJWTToken(authToken) + if err != nil { + throwUnauthorized(w, "Invalid JWT Token") + return + } + + user, err := getUser(claims.Subject) + if err != nil { + throwUnauthorized(w, err.Error()) + return + } + + if !user.Admin { + throwUnauthorized(w, "User is not admin") + return + } + + next(w, r, claims.Subject) + } +} + +// limitMiddleware - Rate limits important stuff +func limitMiddleware(next http.HandlerFunc, limiter *IPRateLimiter) http.HandlerFunc { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + limiter := limiter.GetLimiter(r.RemoteAddr) + if !limiter.Allow() { + jr := jsonResponse{ + Msg: "Too many requests", + } + msg, _ := json.Marshal(&jr) + w.WriteHeader(http.StatusTooManyRequests) + w.Write(msg) + return + } + + next(w, r) + }) +} diff --git a/internal/goscrobble/server_responses.go b/internal/goscrobble/server_responses.go new file mode 100644 index 00000000..207c120a --- /dev/null +++ b/internal/goscrobble/server_responses.go @@ -0,0 +1,58 @@ +package goscrobble + +import ( + "encoding/json" + "errors" + "net/http" +) + +// MIDDLEWARE RESPONSES +// throwUnauthorized - Throws a 403 +func throwUnauthorized(w http.ResponseWriter, m string) { + jr := jsonResponse{ + Err: m, + } + js, _ := json.Marshal(&jr) + err := errors.New(string(js)) + http.Error(w, err.Error(), http.StatusUnauthorized) +} + +// throwUnauthorized - Throws a 403 +func throwBadReq(w http.ResponseWriter, m string) { + jr := jsonResponse{ + Err: m, + } + js, _ := json.Marshal(&jr) + err := errors.New(string(js)) + http.Error(w, err.Error(), http.StatusBadRequest) +} + +// throwOkError - Throws a 403 +func throwOkError(w http.ResponseWriter, m string) { + jr := jsonResponse{ + Err: m, + } + js, _ := json.Marshal(&jr) + w.WriteHeader(http.StatusOK) + w.Write(js) +} + +// throwOkMessage - Throws a happy 200 +func throwOkMessage(w http.ResponseWriter, m string) { + jr := jsonResponse{ + Msg: m, + } + js, _ := json.Marshal(&jr) + w.WriteHeader(http.StatusOK) + w.Write(js) +} + +// throwOkMessage - Throws a happy 200 +func throwInvalidJson(w http.ResponseWriter) { + jr := jsonResponse{ + Err: "Invalid JSON", + } + js, _ := json.Marshal(&jr) + w.WriteHeader(http.StatusBadRequest) + w.Write(js) +} diff --git a/internal/goscrobble/server_static.go b/internal/goscrobble/server_static.go new file mode 100644 index 00000000..7e404244 --- /dev/null +++ b/internal/goscrobble/server_static.go @@ -0,0 +1,43 @@ +package goscrobble + +import ( + "net/http" + "os" + "path/filepath" +) + +// spaHandler - Handles Single Page Applications (React) +type spaHandler struct { + staticPath string + indexPath string +} + +// ServerHTTP - Frontend React server +func (h spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // Get the absolute path to prevent directory traversal + path, err := filepath.Abs(r.URL.Path) + if err != nil { + // If we failed to get the absolute path respond with a 400 bad request and return + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // prepend the path with the path to the static directory + path = filepath.Join(h.staticPath, path) + + // check whether a file exists at the given path + _, err = os.Stat(path) + if os.IsNotExist(err) { + // file does not exist, serve index.html + http.ServeFile(w, r, filepath.Join(h.staticPath, h.indexPath)) + return + } else if err != nil { + // if we got an error (that wasn't that the file doesn't exist) stating the + // file, return a 500 internal server error and stop + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // otherwise, use http.FileServer to serve the static dir + http.FileServer(http.Dir(h.staticPath)).ServeHTTP(w, r) +} diff --git a/internal/goscrobble/smtp.go b/internal/goscrobble/smtp.go new file mode 100644 index 00000000..02ed7447 --- /dev/null +++ b/internal/goscrobble/smtp.go @@ -0,0 +1,18 @@ +package goscrobble + +import ( + "os" + + "github.com/sendgrid/sendgrid-go" + "github.com/sendgrid/sendgrid-go/helpers/mail" +) + +func sendEmail(destName string, destEmail string, subject string, content string) error { + from := mail.NewEmail(os.Getenv("MAIL_FROM_NAME"), os.Getenv("MAIL_FROM_ADDRESS")) + to := mail.NewEmail(destName, destEmail) + message := mail.NewSingleEmail(from, subject, to, content, "") + client := sendgrid.NewSendClient(os.Getenv("SENDGRID_API_KEY")) + + _, err := client.Send(message) + return err +} diff --git a/internal/goscrobble/timers.go b/internal/goscrobble/timers.go new file mode 100644 index 00000000..59f05e19 --- /dev/null +++ b/internal/goscrobble/timers.go @@ -0,0 +1,14 @@ +package goscrobble + +import ( + "fmt" + "time" +) + +func ClearTokenTimer() { + go func() { + for now := range time.Tick(time.Second) { + fmt.Println(now) + } + }() +} diff --git a/internal/goscrobble/user.go b/internal/goscrobble/user.go index 4c4bb54e..4fa4cf63 100644 --- a/internal/goscrobble/user.go +++ b/internal/goscrobble/user.go @@ -7,6 +7,7 @@ import ( "fmt" "log" "net" + "os" "strings" "time" @@ -153,13 +154,13 @@ func insertUser(username string, email string, password []byte, ip net.IP) error } func updateUser(uuid string, field string, value string, ip net.IP) error { - _, err := db.Exec("UPDATE users SET ? = ?, modified_at = NOW(), modified_ip = ? WHERE uuid = ?", field, value, uuid, ip) + _, err := db.Exec("UPDATE users SET `"+field+"` = ?, modified_at = NOW(), modified_ip = ? WHERE uuid = ?", value, uuid, ip) return err } func updateUserDirect(uuid string, field string, value string) error { - _, err := db.Exec("UPDATE users SET ? = ? WHERE uuid = ?", field, value, uuid) + _, err := db.Exec("UPDATE users SET `"+field+"` = ? WHERE uuid = ?", value, uuid) return err } @@ -228,3 +229,90 @@ func getUserByUsername(username string) (User, error) { return user, nil } + +func getUserByEmail(email string) (User, error) { + var user User + err := db.QueryRow("SELECT BIN_TO_UUID(`uuid`, true), `created_at`, `created_ip`, `modified_at`, `modified_ip`, `username`, `email`, `password`, `verified`, `admin` FROM `users` WHERE `email` = ? AND `active` = 1", + email).Scan(&user.UUID, &user.CreatedAt, &user.CreatedIp, &user.ModifiedAt, &user.ModifiedIP, &user.Username, &user.Email, &user.Password, &user.Verified, &user.Admin) + + if err == sql.ErrNoRows { + return user, errors.New("Invalid Email") + } + + return user, nil +} + +func getUserByResetToken(token string) (User, error) { + var user User + err := db.QueryRow("SELECT BIN_TO_UUID(`users`.`uuid`, true), `created_at`, `created_ip`, `modified_at`, `modified_ip`, `username`, `email`, `password`, `verified`, `admin` FROM `users` "+ + "JOIN `resettoken` ON `resettoken`.`user` = `users`.`uuid` WHERE `resettoken`.`token` = ? AND `active` = 1", + token).Scan(&user.UUID, &user.CreatedAt, &user.CreatedIp, &user.ModifiedAt, &user.ModifiedIP, &user.Username, &user.Email, &user.Password, &user.Verified, &user.Admin) + + fmt.Println(err) + + if err == sql.ErrNoRows { + return user, errors.New("Invalid Token") + } + + return user, nil +} +func (user *User) sendResetEmail(ip net.IP) error { + token := generateToken(16) + + // 24 hours + exp := time.Now().AddDate(0, 0, 1) + err := user.saveResetToken(token, exp) + + if err != nil { + return err + } + + content := fmt.Sprintf( + "Someone at %s has request a password reset for %s. Click the following link to reset your password: %s/reset/%s", + ip, user.Username, os.Getenv("GOSCROBBLE_DOMAIN"), token) + + return sendEmail(user.Username, user.Email, "GoScrobble - Password Reset", content) +} + +func (user *User) saveResetToken(token string, expiry time.Time) error { + _, _ = db.Exec("DELETE FROM `resettoken` WHERE `user` = UUID_TO_BIN(?, true)", user.UUID) + _, err := db.Exec("INSERT INTO `resettoken` (`user`, `token`, `expiry`) "+ + "VALUES (UUID_TO_BIN(?, true),?, ?)", user.UUID, token, expiry) + + return err +} + +func clearOldResetTokens() { + _, _ = db.Exec("DELETE FROM `resettoken` WHERE `expiry` < NOW()") +} + +func clearResetToken(token string) error { + _, err := db.Exec("DELETE FROM `resettoken` WHERE `token` = ?", token) + + return err +} + +// checkResetToken - If a token exists check it +func checkResetToken(token string) (bool, error) { + count, err := getDbCount("SELECT COUNT(*) FROM `resettoken` WHERE `token` = ? ", token) + + if err != nil { + return false, err + } + + return count > 0, nil +} + +func (user *User) updatePassword(newPassword string, ip net.IP) error { + hash, err := hashPassword(newPassword) + if err != nil { + return errors.New("Bad password") + } + + _, err = db.Exec("UPDATE `users` SET `password` = ? WHERE `uuid` = UUID_TO_BIN(?, true)", hash, user.UUID) + if err != nil { + return errors.New("Failed to update password") + } + + return nil +} diff --git a/migrations/7_resettoken.down.sql b/migrations/7_resettoken.down.sql new file mode 100644 index 00000000..645d7447 --- /dev/null +++ b/migrations/7_resettoken.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS `resettoken`; diff --git a/migrations/7_resettoken.up.sql b/migrations/7_resettoken.up.sql new file mode 100644 index 00000000..b233f80d --- /dev/null +++ b/migrations/7_resettoken.up.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS `resettoken` ( + `user` BINARY(16) PRIMARY KEY, + `token` VARCHAR(64) NOT NULL, + `expiry` DATETIME NOT NULL, + KEY `tokenLookup` (`token`) +) DEFAULT CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci; diff --git a/web/src/Api/index.js b/web/src/Api/index.js index 382b055f..a80f8e0c 100644 --- a/web/src/Api/index.js +++ b/web/src/Api/index.js @@ -32,9 +32,14 @@ export const PostLogin = (formValues) => { toast.error(response.data.error ? response.data.error: 'An Unknown Error has occurred'); return null } - }) - .catch(() => { - toast.error('Failed to connect'); + }).catch((error) => { + if (error.response === 401) { + return {}; + } else if (error.response === 429) { + toast.error('Rate limited. Please try again shortly') + } else { + toast.error('Failed to connect'); + } return Promise.resolve(); }); }; @@ -51,20 +56,70 @@ export const PostRegister = (formValues) => { return Promise.reject(); } - }) - .catch(() => { + }).catch((error) => { + if (error.response === 401) { + return {}; + } else if (error.response === 429) { + toast.error('Rate limited. Please try again shortly') + } else { toast.error('Failed to connect'); - return Promise.resolve(); - }); + } + return Promise.resolve(); + }); +}; + +export const PostResetPassword = (formValues) => { + return axios.post(process.env.REACT_APP_API_URL + "resetpassword", formValues) + .then((response) => { + if (response.data.message) { + toast.success(response.data.message); + + return Promise.resolve(); + } else { + toast.error(response.data.error ? response.data.error: 'An Unknown Error has occurred'); + + return Promise.reject(); + } + }).catch((error) => { + if (error.response === 401) { + return {}; + } else if (error.response === 429) { + toast.error('Rate limited. Please try again shortly') + } else { + toast.error('Failed to connect'); + } + return Promise.resolve(); + }); +}; + +export const sendPasswordReset = (values) => { + return axios.post(process.env.REACT_APP_API_URL + "sendreset", values).then( + (data) => { + return data.data; + }).catch((error) => { + if (error.response === 401) { + return {}; + } else if (error.response === 429) { + toast.error('Rate limited. Please try again shortly') + } else { + toast.error('Failed to connect'); + } + return {}; + }); }; export const getStats = () => { return axios.get(process.env.REACT_APP_API_URL + "stats").then( (data) => { return data.data; + }).catch((error) => { + if (error.response === 401) { + return {}; + } else if (error.response === 429) { + toast.error('Rate limited. Please try again shortly') + } else { + toast.error('Failed to connect'); } - ).catch(() => { - toast.error('Failed to connect'); return {}; }); }; @@ -73,8 +128,14 @@ export const getRecentScrobbles = (id) => { return axios.get(process.env.REACT_APP_API_URL + "user/" + id + "/scrobbles", { headers: getHeaders() }) .then((data) => { return data.data; - }).catch(() => { - toast.error('Failed to connect'); + }).catch((error) => { + if (error.response === 401) { + return {}; + } else if (error.response === 429) { + toast.error('Rate limited. Please try again shortly') + } else { + toast.error('Failed to connect'); + } return {}; }); }; @@ -83,8 +144,14 @@ export const getConfigs = () => { return axios.get(process.env.REACT_APP_API_URL + "config", { headers: getHeaders() }) .then((data) => { return data.data; - }).catch(() => { - toast.error('Failed to connect'); + }).catch((error) => { + if (error.response === 401) { + return {}; + } else if (error.response === 429) { + toast.error('Rate limited. Please try again shortly') + } else { + toast.error('Failed to connect'); + } return {}; }); }; @@ -103,18 +170,30 @@ export const postConfigs = (values, toggle) => { } else if (data.data && data.data.error) { toast.error(data.data.error); } - }) - .catch(() => { - toast.error('Error updating values'); - }); + }).catch((error) => { + if (error.response === 401) { + return {}; + } else if (error.response === 429) { + toast.error('Rate limited. Please try again shortly') + } else { + toast.error('Failed to connect'); + } + return {}; + }); }; export const getProfile = (userName) => { return axios.get(process.env.REACT_APP_API_URL + "profile/" + userName, { headers: getHeaders() }) .then((data) => { return data.data; - }).catch(() => { - toast.error('Failed to connect'); + }).catch((error) => { + if (error.response === 401) { + return {}; + } else if (error.response === 429) { + toast.error('Rate limited. Please try again shortly') + } else { + toast.error('Failed to connect'); + } return {}; }); }; @@ -123,8 +202,35 @@ export const getUser = () => { return axios.get(process.env.REACT_APP_API_URL + "user", { headers: getHeaders() }) .then((data) => { return data.data; - }).catch(() => { - toast.error('Failed to connect'); + }).catch((error) => { + if (error.response === 401) { + return {}; + } else if (error.response === 429) { + toast.error('Rate limited. Please try again shortly') + } else { + toast.error('Failed to connect'); + } return {}; }); }; + +export const validateResetPassword = (tokenStr) => { + return axios.post(process.env.REACT_APP_API_URL + "resetpassword", { token: tokenStr }) + .then((data) => { + if (data.error) { + toast.error(data.error); + return {valid: false} + } + return data.data; + }).catch((error) => { + if (error.response === 401) { + return {}; + } else if (error.response === 429) { + toast.error('Rate limited. Please try again shortly') + } else { + toast.error('Failed to connect'); + } + return {}; + }); +}; + diff --git a/web/src/App.js b/web/src/App.js index e0e292f4..4cf1fb2c 100644 --- a/web/src/App.js +++ b/web/src/App.js @@ -7,6 +7,7 @@ import User from './Pages/User'; import Admin from './Pages/Admin'; import Login from './Pages/Login'; import Register from './Pages/Register'; +import Reset from './Pages/Reset'; import Navigation from './Components/Navigation'; @@ -38,6 +39,9 @@ const App = () => { + + + ); diff --git a/web/src/Contexts/AuthContextProvider.js b/web/src/Contexts/AuthContextProvider.js index 0b0907b1..80445fe5 100644 --- a/web/src/Contexts/AuthContextProvider.js +++ b/web/src/Contexts/AuthContextProvider.js @@ -1,7 +1,7 @@ import React, { useState, useEffect } from 'react'; import { toast } from 'react-toastify'; import AuthContext from './AuthContext'; -import { PostLogin, PostRegister } from '../Api/index'; +import { PostLogin, PostRegister, PostResetPassword } from '../Api/index'; const AuthContextProvider = ({ children }) => { const [user, setUser] = useState(); @@ -30,11 +30,14 @@ const AuthContextProvider = ({ children }) => { const Register = (formValues) => { setLoading(true); return PostRegister(formValues).then(response => { - // Do stuff here? setLoading(false); }); }; + const ResetPassword = (formValues) => { + return PostResetPassword(formValues) + } + const Logout = () => { localStorage.removeItem("user"); setUser(null) @@ -47,6 +50,7 @@ const AuthContextProvider = ({ children }) => { Logout, Login, Register, + ResetPassword, loading, user, }} diff --git a/web/src/Pages/Admin.js b/web/src/Pages/Admin.js index 1185169e..ffdb546b 100644 --- a/web/src/Pages/Admin.js +++ b/web/src/Pages/Admin.js @@ -1,4 +1,5 @@ import React, { useContext, useState, useEffect } from 'react'; +import { useHistory } from 'react-router-dom'; import '../App.css'; import './Login.css'; import { Button } from 'reactstrap'; @@ -9,6 +10,7 @@ import { Switch } from 'formik-material-ui'; import { getConfigs, postConfigs } from '../Api/index' const Admin = () => { + const history = useHistory(); const { user } = useContext(AuthContext); const [loading, setLoading] = useState(true); const [configs, setConfigs] = useState({}) @@ -38,11 +40,7 @@ const Admin = () => { } if (!user || !user.admin) { - return ( -
-

Unauthorized

-
- ) + history.push("/login") } return ( diff --git a/web/src/Pages/Dashboard.js b/web/src/Pages/Dashboard.js index 87503e2e..f2cb9ac8 100644 --- a/web/src/Pages/Dashboard.js +++ b/web/src/Pages/Dashboard.js @@ -8,7 +8,7 @@ import ScrobbleTable from "../Components/ScrobbleTable"; import AuthContext from '../Contexts/AuthContext'; const Dashboard = () => { - const history = useHistory(); + // const history = useHistory(); let { user } = useContext(AuthContext); let [loading, setLoading] = useState(true); let [dashboardData, setDashboardData] = useState({}); diff --git a/web/src/Pages/Login.js b/web/src/Pages/Login.js index ceb15dfb..452ad1d6 100644 --- a/web/src/Pages/Login.js +++ b/web/src/Pages/Login.js @@ -16,6 +16,10 @@ const Login = () => { history.push("/dashboard"); } + const redirectReset = () => { + history.push("/reset") + } + return (

@@ -42,7 +46,6 @@ const Login = () => { @@ -53,6 +56,14 @@ const Login = () => { className="loginButton" disabled={loading} >{loading ? : "Login"} +

+

diff --git a/web/src/Pages/Profile.js b/web/src/Pages/Profile.js index aa1a3daf..fe44a506 100644 --- a/web/src/Pages/Profile.js +++ b/web/src/Pages/Profile.js @@ -35,7 +35,7 @@ const Profile = (route) => { ) } - if (!username || Object.keys(profile).length === 0) { + if (!username || !profile.username) { return (
Unable to fetch user @@ -50,7 +50,7 @@ const Profile = (route) => {
Last 10 scrobbles...
- +
); diff --git a/web/src/Pages/Reset.css b/web/src/Pages/Reset.css new file mode 100644 index 00000000..64b62e2d --- /dev/null +++ b/web/src/Pages/Reset.css @@ -0,0 +1,15 @@ +.resetBody { + padding: 20px 5px 5px 5px; + font-size: 16pt; + width: 300px; +} + +.resetFields { + width: 100%; +} + +.resetButton { + height: 50px; + width: 100%; + margin-top:-5px; +} \ No newline at end of file diff --git a/web/src/Pages/Reset.js b/web/src/Pages/Reset.js new file mode 100644 index 00000000..5590334e --- /dev/null +++ b/web/src/Pages/Reset.js @@ -0,0 +1,153 @@ +import React, { useState, useEffect, useContext } from 'react'; +import '../App.css'; +import './Reset.css'; +import { Button } from 'reactstrap'; +import { Formik, Form, Field } from 'formik'; +import ScaleLoader from 'react-spinners/ScaleLoader'; +import { validateResetPassword, sendPasswordReset } from '../Api/index'; +import AuthContext from '../Contexts/AuthContext'; + +const Reset = (route) => { + let boolTrue = true; + const [loading, setLoading] = useState(true); + const [reset, setReset] = useState({}); + const [sent, setSent] = useState(false); + + let { ResetPassword } = useContext(AuthContext); + + let reqToken = false; + if (route && route.match && route.match.params && route.match.params.token) { + reqToken = route.match.params.token + } + + const sendReset = (values) => { + sendPasswordReset(values).then(() => { + setSent(true); + }); + } + + useEffect(() => { + if (!reqToken) { + setLoading(false); + return false; + } + + validateResetPassword(reqToken) + .then(data => { + setReset(data); + console.log(data) + setLoading(false); + }) + }, [reqToken]) + + if (loading) { + return ( +
+ +
+ ) + } + + if (sent) { + return ( +
+

+ Check your email! +

+
+ ) + } + + if (!reqToken) { + return ( +
+

+ Reset Password +

+
+ sendReset(values)} + > +
+ +

+ +
+
+
+
+ ) + } + + if (reqToken && !reset.valid) { + return ( +
+ Invalid Reset Token or Token expired +
+ ) + } + + return ( +
+

+ Reset Password +

+
+ ResetPassword(values)} + > +
+ +
+ + +

+ + +
+
+
+ ); +} + +export default Reset;