diff --git a/cmd/app/cli/cli.go b/cmd/app/cli/cli.go index d92cc32..054893b 100644 --- a/cmd/app/cli/cli.go +++ b/cmd/app/cli/cli.go @@ -3,12 +3,19 @@ package cli import ( "context" + "github.com/urfave/cli/v2" "go.uber.org/fx" "exusiai.dev/roguestats-backend/internal/app" "exusiai.dev/roguestats-backend/internal/app/appenv" ) -func Start(module fx.Option) { - app.New(appenv.Declare(appenv.EnvCLI), module).Start(context.Background()) +func Start(module fx.Option) error { + return app.New(appenv.Declare(appenv.EnvCLI), module). + Start(context.Background()) +} + +func RunFunc(ctx *cli.Context, r any) error { + return app.New(appenv.Declare(appenv.EnvCLI), fx.Supply(ctx), fx.Invoke(r)). + Start(ctx.Context) } diff --git a/cmd/app/cli/script/command.go b/cmd/app/cli/script/command.go index 0125d59..043c73a 100644 --- a/cmd/app/cli/script/command.go +++ b/cmd/app/cli/script/command.go @@ -3,6 +3,7 @@ package script import ( "log" + "exusiai.dev/roguestats-backend/cmd/app/cli/script/syncschema" "github.com/urfave/cli/v2" ) @@ -48,6 +49,7 @@ func Command() *cli.Command { return nil }, }, + syncschema.Command(), }, } } diff --git a/cmd/app/cli/script/import_util.go b/cmd/app/cli/script/import_util.go index 84bd450..1b3e2f2 100644 --- a/cmd/app/cli/script/import_util.go +++ b/cmd/app/cli/script/import_util.go @@ -29,9 +29,9 @@ func PostEvent(content map[string]any, researchID string) { req := graphql.NewRequest(` mutation CreateEvent($input: CreateEventInput!) { createEvent(input: $input) { - content + content } - }`, + }`, ) userAgent := "cli" input := model.CreateEventInput{ @@ -40,7 +40,7 @@ func PostEvent(content map[string]any, researchID string) { UserAgent: userAgent, } req.Var("input", input) - req.Header.Set("Authorization", "Bearer eyJhbGciOiJFUzUxMiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyb2d1ZXN0YXRzIiwiZXhwIjoxNjk0MjA0MjY1LCJpYXQiOjE2OTI5OTQ2NjUsImlzcyI6InJvZ3Vlc3RhdHMvdjAuMC4wIiwibmJmIjoxNjkyOTk0NjY1LCJzdWIiOiIwMWg4cTVlYnJuNWV0aG0xcDZ6anhyOWVmdyJ9.AHlIYrx7tKj6nnXO4MYRd_0mXqzOVWPyG6FHidPitfI2IbrtZI3-lXA-bZP_nl0Op7d4TgzacdYwJPDgYGLoZcznAfopT-ahoHmDZrflhrK-Soo8ji7OZENjOIH5VetkkTaKl9zuqdAivds4DQPefSYngsn5vqzIgIZhaoR8nJoaq6MT") + req.Header.Set("Authorization", "Bearer <token>") ctx := context.Background() var respData any if err := client.Run(ctx, req, &respData); err != nil { diff --git a/cmd/app/cli/script/syncschema/command.go b/cmd/app/cli/script/syncschema/command.go new file mode 100644 index 0000000..e9d2c4b --- /dev/null +++ b/cmd/app/cli/script/syncschema/command.go @@ -0,0 +1,24 @@ +package syncschema + +import ( + appcli "exusiai.dev/roguestats-backend/cmd/app/cli" + "github.com/urfave/cli/v2" +) + +func Command() *cli.Command { + return &cli.Command{ + Name: "sync-schema", + Usage: "Sync JSON Schemas under DIR with the database.", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "dir", + Aliases: []string{"d"}, + Usage: "Directory containing the JSON Schemas.", + Value: "./schema", + }, + }, + Action: func(c *cli.Context) error { + return appcli.RunFunc(c, Run) + }, + } +} diff --git a/cmd/app/cli/script/syncschema/syncschema.go b/cmd/app/cli/script/syncschema/syncschema.go new file mode 100644 index 0000000..293a922 --- /dev/null +++ b/cmd/app/cli/script/syncschema/syncschema.go @@ -0,0 +1,95 @@ +package syncschema + +import ( + "io" + "os" + "path/filepath" + "strings" + + "exusiai.dev/roguestats-backend/internal/ent" + "exusiai.dev/roguestats-backend/internal/ent/research" + "github.com/rs/zerolog/log" + "github.com/urfave/cli/v2" + "go.uber.org/fx" +) + +type SyncSchemaCommandDeps struct { + fx.In + + Ent *ent.Client +} + +func Run(c *cli.Context, d SyncSchemaCommandDeps) error { + tx, err := d.Ent.BeginTx(c.Context, nil) + if err != nil { + return err + } + defer tx.Rollback() + + dir := c.String("dir") + err = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() || filepath.Ext(path) != ".json" { + return nil + } + + // id is the beginning of the filename until the first dot. + segments := strings.Split(info.Name(), ".") + id := segments[0] + name := segments[1] + log.Info().Str("path", path).Str("id", id).Msg("processing research") + + // Check if the research exists. + research, err := tx.Research.Query().Where(research.ID(id)).Only(c.Context) + if err != nil { + if ent.IsNotFound(err) { + log.Info().Str("id", id).Msg("research does not exist, creating") + // Create the research. + research, err = tx.Research.Create(). + SetID(id). + SetName(name). + Save(c.Context) + if err != nil { + return err + } + } else { + return err + } + } + + jsonBytes, err := minifiedJsonFile(path) + if err != nil { + return err + } + + // Update the research schema. + log.Info().Str("id", id).Msg("updating research schema") + _, err = research.Update(). + SetSchema(jsonBytes). + Save(c.Context) + if err != nil { + return err + } + + return nil + }) + + if err != nil { + return err + } + log.Info().Msg("committing transaction") + + return tx.Commit() +} + +func minifiedJsonFile(file string) ([]byte, error) { + f, err := os.Open(file) + if err != nil { + return nil, err + } + defer f.Close() + + return io.ReadAll(f) +} diff --git a/go.mod b/go.mod index 994c8e7..d8ebf8e 100644 --- a/go.mod +++ b/go.mod @@ -18,10 +18,12 @@ require ( github.com/machinebox/graphql v0.2.2 github.com/oklog/ulid/v2 v2.1.0 github.com/pkg/errors v0.9.1 + github.com/redis/go-redis/v9 v9.1.0 github.com/resendlabs/resend-go v1.7.0 github.com/rs/zerolog v1.30.0 github.com/urfave/cli/v2 v2.25.7 github.com/vektah/gqlparser/v2 v2.5.8 + github.com/wagslane/go-password-validator v0.3.0 go.uber.org/fx v1.20.0 golang.org/x/crypto v0.12.0 gopkg.in/guregu/null.v4 v4.0.0 @@ -30,7 +32,10 @@ require ( require ( ariga.io/atlas v0.10.2-0.20230427182402-87a07dfb83bf // indirect github.com/agext/levenshtein v1.2.1 // indirect + github.com/ake-persson/mapslice-json v0.0.0-20210720081907-22c8edf57807 // indirect github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/go-openapi/inflect v0.19.0 // indirect github.com/google/go-cmp v0.5.9 // indirect github.com/hashicorp/errwrap v1.0.0 // indirect diff --git a/go.sum b/go.sum index e2bef57..f018726 100644 --- a/go.sum +++ b/go.sum @@ -11,6 +11,8 @@ github.com/agext/levenshtein v1.2.1 h1:QmvMAjj2aEICytGiWzmxoE0x2KZvE0fvmqMOfy2tj github.com/agext/levenshtein v1.2.1/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= github.com/agnivade/levenshtein v1.1.1 h1:QY8M92nrzkmr798gCo3kmMyqXFzdQVpxLlGPRBij0P8= github.com/agnivade/levenshtein v1.1.1/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo= +github.com/ake-persson/mapslice-json v0.0.0-20210720081907-22c8edf57807 h1:w3nrGk00TWs/4iZ3Q0k9c0vL0e/wRziArKU4e++d/nA= +github.com/ake-persson/mapslice-json v0.0.0-20210720081907-22c8edf57807/go.mod h1:fGnnfniJiO/ajHAVHqMSUSL8sE9LmU9rzclCtoeB+y8= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= @@ -22,6 +24,11 @@ github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkE github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A= +github.com/bsm/ginkgo/v2 v2.9.5 h1:rtVBYPs3+TC5iLUVOis1B9tjLTup7Cj5IfzosKtvTJ0= +github.com/bsm/gomega v1.26.0 h1:LhQm+AFcgV2M0WyKroMASzAzCAJVpAxQXv4SaI9a69Y= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= @@ -30,8 +37,11 @@ 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/dchest/uniuri v1.2.0 h1:koIcOUdrTIivZgSLhHQvKgqdWZq5d7KdMEWF1Ud6+5g= github.com/dchest/uniuri v1.2.0/go.mod h1:fSzm4SLHzNZvWLvWJew423PhAzkpNQYq+uNLq4kxhkY= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48 h1:fRzb/w+pyskVMQ+UbP35JkH8yB7MYb4q/qhBarqZE6g= github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= +github.com/fzipp/gocyclo v0.3.1/go.mod h1:DJHO6AUmbdqj2ET4Z9iArSuwWgYDRryYt2wASxc7x3E= github.com/go-openapi/inflect v0.19.0 h1:9jCH9scKIbHeV9m12SmPilScz6krDxKRasNNSNPXu/4= github.com/go-openapi/inflect v0.19.0/go.mod h1:lHpZVlpIQqLyKwJ4N+YSc9hchQy/i12fJykb83CRBH4= github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= @@ -49,6 +59,7 @@ github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gordonklaus/ineffassign v0.0.0-20210522101830-0589229737b2/go.mod h1:M9mZEtGIsR1oDaZagNPNG9iq9n2HrhZ17dsXk73V3Lw= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= @@ -63,6 +74,7 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= +github.com/kisielk/errcheck v1.6.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/klauspost/compress v1.16.3 h1:XuJt9zzcnaz6a16/OU53ZjWp/v7/42WcR5t2a0PcNQY= github.com/klauspost/compress v1.16.3/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= @@ -100,6 +112,8 @@ 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/redis/go-redis/v9 v9.1.0 h1:137FnGdk+EQdCbye1FW+qOEcY5S+SpY9T0NiuqvtfMY= +github.com/redis/go-redis/v9 v9.1.0/go.mod h1:urWj3He21Dj5k4TK1y59xH8Uj6ATueP8AH1cY3lZl4c= github.com/resendlabs/resend-go v1.7.0 h1:DycOqSXtw2q7aB+Nt9DDJUDtaYcrNPGn1t5RFposas0= github.com/resendlabs/resend-go v1.7.0/go.mod h1:yip1STH7Bqfm4fD0So5HgyNbt5taG5Cplc4xXxETyLI= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= @@ -137,8 +151,13 @@ github.com/vmihailenco/msgpack/v5 v5.3.5/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q github.com/vmihailenco/tagparser v0.1.1/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgqMEUPoW2WPyhdI= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +github.com/wagslane/go-password-validator v0.3.0 h1:vfxOPzGHkz5S146HDpavl0cw1DSVP061Ry2PX0/ON6I= +github.com/wagslane/go-password-validator v0.3.0/go.mod h1:TI1XJ6T5fRdRnHqHt14pvy1tNVnrwe7m3/f1f2fDphQ= github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/zclconf/go-cty v1.8.0 h1:s4AvqaeQzJIu3ndv4gVIhplVD0krU+bgrcLSVUnaWuA= github.com/zclconf/go-cty v1.8.0/go.mod h1:vVKLxnk3puL4qRAv72AO+W99LUD4da90g3uUAzyuvAk= go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ= @@ -153,30 +172,63 @@ go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTV go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60= go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk= golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= golang.org/x/exp v0.0.0-20221230185412-738e83a70c30 h1:m9O6OTJ627iFnN2JIWfdqlZCzneRO6EEBsHXI25P8ws= golang.org/x/exp v0.0.0-20221230185412-738e83a70c30/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= +golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.10.0 h1:lFO9qtOdlre5W1jxS3r/4szv2/6iXxScdzjoBMXNhYk= golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +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-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.2.0 h1:PUR+T4wwASmuSTYdKjYHI5TD22Wy5ogLU5qZCOLxBrI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.12.0 h1:k+n5B8goJNdU7hSvEtMUz3d1Q6D/XW4COJSJR6fN0mc= golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= +golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.9.3 h1:Gn1I8+64MsuTb/HpH+LmQtNas23LhUVr3rYZ0eKuaMM= golang.org/x/tools v0.9.3/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc= +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/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/app/appconfig/spec.go b/internal/app/appconfig/spec.go index 19f1266..73131ff 100644 --- a/internal/app/appconfig/spec.go +++ b/internal/app/appconfig/spec.go @@ -11,6 +11,9 @@ type ConfigSpec struct { // DatabaseURL is the URL to the PostgreSQL database. DatabaseURL string `split_words:"true" required:"true"` + // RedisURL is the URL to the Redis database. + RedisURL string `split_words:"true" required:"true"` + // ServiceListenAddress is the address that the Fiber HTTP server will listen on. ServiceListenAddress string `split_words:"true" required:"true" default:":3000"` @@ -39,6 +42,9 @@ type ConfigSpec struct { // ResendApiKey is the API key used to send emails via Resend. ResendApiKey string `split_words:"true" required:"true"` + + // PasswordResetTokenTTL is the time to live of the password reset token. + PasswordResetTokenTTL time.Duration `split_words:"true" required:"true" default:"1h"` } type Config struct { diff --git a/internal/blob/template/password-reset.html.tmpl b/internal/blob/template/password-reset.html.tmpl new file mode 100644 index 0000000..c832677 --- /dev/null +++ b/internal/blob/template/password-reset.html.tmpl @@ -0,0 +1 @@ +<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html lang="zh"><head data-id="__react-email-head"><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/><meta charSet="utf-8"/><title>你的 RogueStats 登录信息重置请求已就绪</title></head><div id="__react-email-preview" style="display:none;overflow:hidden;line-height:1px;opacity:0;max-height:0;max-width:0">Hi, {{ .Username }}! 你的 RogueStats 登录信息重置请求已就绪<div> </div></div><body data-id="__react-email-body" style="background-color:rgb(255,255,255);margin-top:auto;margin-bottom:auto;margin-left:auto;margin-right:auto;font-family:ui-sans-serif, system-ui, -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"><table align="center" width="100%" data-id="__react-email-container" role="presentation" cellSpacing="0" cellPadding="0" border="0" style="max-width:600px;margin-left:auto;margin-right:auto;padding:1rem"><tbody><tr style="width:100%"><td><table align="center" width="100%" data-id="react-email-section" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-top:32px"><tbody><tr><td><img data-id="react-email-img" alt="Penguin Statistics" src="https://penguin.upyun.galvincdn.com/logos/penguin_stats_logo.png" width="64" height="64" style="display:block;outline:none;border:none;text-decoration:none"/></td></tr></tbody></table><h1 data-id="react-email-heading" style="color:rgb(0,0,0);font-size:24px;font-weight:400;padding:0px;margin-top:30px;margin-bottom:30px;margin-left:0px;margin-right:0px">你的 RogueStats 登录信息重置请求已就绪</h1><p data-id="react-email-text" style="font-size:0.875rem;line-height:24px;margin:16px 0;color:rgb(0,0,0);font-weight:400">{{ .Username }},你好:</p><p data-id="react-email-text" style="font-size:0.875rem;line-height:24px;margin:16px 0;color:rgb(0,0,0);font-weight:400">你的 RogueStats 登录信息重置请求已就绪,请点击下方按钮以重置你的 RogueStats 登录信息。此次登录信息重置请求将在 {{ .TokenTTL }} 后失效。</p><table align="center" width="100%" data-id="react-email-section" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="text-align:center;margin-top:32px;margin-bottom:32px"><tbody><tr><td><a href="https://rogue.penguin-stats.io/auth/reset-password?token={{ .Token }}" data-id="react-email-button" target="_blank" style="line-height:100%;text-decoration:none;display:inline-block;max-width:100%;padding:12px 20px;background-color:rgb(0,0,0);border-radius:0.25rem;color:rgb(255,255,255);font-size:12px;font-weight:600;text-decoration-line:none;text-align:center"><span></span><span style="max-width:100%;display:inline-block;line-height:120%;mso-padding-alt:0px;mso-text-raise:9px">重置密码</span><span></span></a></td></tr></tbody></table><p data-id="react-email-text" style="font-size:0.875rem;line-height:24px;margin:16px 0;color:rgb(0,0,0);font-weight:400">如果你有任何问题,欢迎随时联系 RogueStats 开发组。</p><p data-id="react-email-text" style="font-size:0.875rem;line-height:24px;margin:16px 0;color:rgb(0,0,0);font-weight:400">祝好,</p><p data-id="react-email-text" style="font-size:0.875rem;line-height:24px;margin:16px 0;color:rgb(0,0,0);font-weight:400">RogueStats 开发组</p><hr data-id="react-email-hr" style="width:100%;border:none;border-top:1px solid #eaeaea;height:1px;background-color:rgb(234,234,234);margin-top:26px;margin-bottom:26px;margin-left:0px;margin-right:0px"/><p data-id="react-email-text" style="font-size:12px;line-height:24px;margin:16px 0;color:rgb(102,102,102);margin-top:0px;margin-bottom:0px">RogueStats 是一个 Penguin Statistics 下的所属项目。Penguin Statistics 是一个由玩家驱动的明日方舟数据统计网站,致力于为玩家提供更好的游戏体验。</p><p data-id="react-email-text" style="font-size:12px;line-height:24px;margin:16px 0;color:rgb(102,102,102);margin-bottom:0px;margin-top:1rem">此邮件是为 Penguin Statistics 自动发送,请勿回复。</p></td></tr></tbody></table></body></html> \ No newline at end of file diff --git a/internal/blob/template/password-reset.txt.tmpl b/internal/blob/template/password-reset.txt.tmpl new file mode 100644 index 0000000..a74aef0 --- /dev/null +++ b/internal/blob/template/password-reset.txt.tmpl @@ -0,0 +1,21 @@ +你的 RogueStats 登录信息重置请求已就绪 + +{{ .Username }},你好: + +你的 RogueStats 登录信息重置请求已就绪,请点击下方按钮以重置你的 RogueStats 登录信息。此次登录信息重置请求将在 {{ .TokenTTL +}} 后失效。 + +重置密码 [https://rogue.penguin-stats.io/auth/reset-password?token={{ .Token }}] + +如果你有任何问题,欢迎随时联系 RogueStats 开发组。 + +祝好, + +RogueStats 开发组 + +-------------------------------------------------------------------------------- + +RogueStats 是一个 Penguin Statistics 下的所属项目。Penguin Statistics +是一个由玩家驱动的明日方舟数据统计网站,致力于为玩家提供更好的游戏体验。 + +此邮件是为 Penguin Statistics 自动发送,请勿回复。 \ No newline at end of file diff --git a/internal/ent/migrate/schema.go b/internal/ent/migrate/schema.go index 836d54b..51a3d9f 100644 --- a/internal/ent/migrate/schema.go +++ b/internal/ent/migrate/schema.go @@ -63,7 +63,7 @@ var ( ResearchesColumns = []*schema.Column{ {Name: "research_id", Type: field.TypeString, Unique: true}, {Name: "name", Type: field.TypeString, Size: 64}, - {Name: "schema", Type: field.TypeJSON}, + {Name: "schema", Type: field.TypeBytes}, } // ResearchesTable holds the schema information for the "researches" table. ResearchesTable = &schema.Table{ diff --git a/internal/ent/mutation.go b/internal/ent/mutation.go index 4ec6c0d..1de8786 100644 --- a/internal/ent/mutation.go +++ b/internal/ent/mutation.go @@ -684,7 +684,7 @@ type ResearchMutation struct { typ string id *string name *string - schema *map[string]interface{} + schema *[]byte clearedFields map[string]struct{} events map[string]struct{} removedevents map[string]struct{} @@ -835,12 +835,12 @@ func (m *ResearchMutation) ResetName() { } // SetSchema sets the "schema" field. -func (m *ResearchMutation) SetSchema(value map[string]interface{}) { - m.schema = &value +func (m *ResearchMutation) SetSchema(b []byte) { + m.schema = &b } // Schema returns the value of the "schema" field in the mutation. -func (m *ResearchMutation) Schema() (r map[string]interface{}, exists bool) { +func (m *ResearchMutation) Schema() (r []byte, exists bool) { v := m.schema if v == nil { return @@ -851,7 +851,7 @@ func (m *ResearchMutation) Schema() (r map[string]interface{}, exists bool) { // OldSchema returns the old "schema" field's value of the Research entity. // If the Research object wasn't provided to the builder, the object is fetched from the database. // An error is returned if the mutation operation is not UpdateOne, or the database query fails. -func (m *ResearchMutation) OldSchema(ctx context.Context) (v map[string]interface{}, err error) { +func (m *ResearchMutation) OldSchema(ctx context.Context) (v []byte, err error) { if !m.op.Is(OpUpdateOne) { return v, errors.New("OldSchema is only allowed on UpdateOne operations") } @@ -1007,7 +1007,7 @@ func (m *ResearchMutation) SetField(name string, value ent.Value) error { m.SetName(v) return nil case research.FieldSchema: - v, ok := value.(map[string]interface{}) + v, ok := value.([]byte) if !ok { return fmt.Errorf("unexpected type %T for field %s", value, name) } diff --git a/internal/ent/research.go b/internal/ent/research.go index 8401c68..12dd90f 100644 --- a/internal/ent/research.go +++ b/internal/ent/research.go @@ -3,7 +3,6 @@ package ent import ( - "encoding/json" "fmt" "strings" @@ -20,7 +19,7 @@ type Research struct { // Name holds the value of the "name" field. Name string `json:"name,omitempty"` // Schema holds the value of the "schema" field. - Schema map[string]interface{} `json:"schema,omitempty"` + Schema []byte `json:"schema,omitempty"` // Edges holds the relations/edges for other nodes in the graph. // The values are being populated by the ResearchQuery when eager-loading is set. Edges ResearchEdges `json:"edges"` @@ -88,10 +87,8 @@ func (r *Research) assignValues(columns []string, values []any) error { case research.FieldSchema: if value, ok := values[i].(*[]byte); !ok { return fmt.Errorf("unexpected type %T for field schema", values[i]) - } else if value != nil && len(*value) > 0 { - if err := json.Unmarshal(*value, &r.Schema); err != nil { - return fmt.Errorf("unmarshal field schema: %w", err) - } + } else if value != nil { + r.Schema = *value } default: r.selectValues.Set(columns[i], values[i]) diff --git a/internal/ent/research/where.go b/internal/ent/research/where.go index 414cda7..e2c76e9 100644 --- a/internal/ent/research/where.go +++ b/internal/ent/research/where.go @@ -68,6 +68,11 @@ func Name(v string) predicate.Research { return predicate.Research(sql.FieldEQ(FieldName, v)) } +// Schema applies equality check predicate on the "schema" field. It's identical to SchemaEQ. +func Schema(v []byte) predicate.Research { + return predicate.Research(sql.FieldEQ(FieldSchema, v)) +} + // NameEQ applies the EQ predicate on the "name" field. func NameEQ(v string) predicate.Research { return predicate.Research(sql.FieldEQ(FieldName, v)) @@ -133,6 +138,46 @@ func NameContainsFold(v string) predicate.Research { return predicate.Research(sql.FieldContainsFold(FieldName, v)) } +// SchemaEQ applies the EQ predicate on the "schema" field. +func SchemaEQ(v []byte) predicate.Research { + return predicate.Research(sql.FieldEQ(FieldSchema, v)) +} + +// SchemaNEQ applies the NEQ predicate on the "schema" field. +func SchemaNEQ(v []byte) predicate.Research { + return predicate.Research(sql.FieldNEQ(FieldSchema, v)) +} + +// SchemaIn applies the In predicate on the "schema" field. +func SchemaIn(vs ...[]byte) predicate.Research { + return predicate.Research(sql.FieldIn(FieldSchema, vs...)) +} + +// SchemaNotIn applies the NotIn predicate on the "schema" field. +func SchemaNotIn(vs ...[]byte) predicate.Research { + return predicate.Research(sql.FieldNotIn(FieldSchema, vs...)) +} + +// SchemaGT applies the GT predicate on the "schema" field. +func SchemaGT(v []byte) predicate.Research { + return predicate.Research(sql.FieldGT(FieldSchema, v)) +} + +// SchemaGTE applies the GTE predicate on the "schema" field. +func SchemaGTE(v []byte) predicate.Research { + return predicate.Research(sql.FieldGTE(FieldSchema, v)) +} + +// SchemaLT applies the LT predicate on the "schema" field. +func SchemaLT(v []byte) predicate.Research { + return predicate.Research(sql.FieldLT(FieldSchema, v)) +} + +// SchemaLTE applies the LTE predicate on the "schema" field. +func SchemaLTE(v []byte) predicate.Research { + return predicate.Research(sql.FieldLTE(FieldSchema, v)) +} + // HasEvents applies the HasEdge predicate on the "events" edge. func HasEvents() predicate.Research { return predicate.Research(func(s *sql.Selector) { diff --git a/internal/ent/research_create.go b/internal/ent/research_create.go index ab55073..dcc9c7f 100644 --- a/internal/ent/research_create.go +++ b/internal/ent/research_create.go @@ -27,8 +27,8 @@ func (rc *ResearchCreate) SetName(s string) *ResearchCreate { } // SetSchema sets the "schema" field. -func (rc *ResearchCreate) SetSchema(m map[string]interface{}) *ResearchCreate { - rc.mutation.SetSchema(m) +func (rc *ResearchCreate) SetSchema(b []byte) *ResearchCreate { + rc.mutation.SetSchema(b) return rc } @@ -155,7 +155,7 @@ func (rc *ResearchCreate) createSpec() (*Research, *sqlgraph.CreateSpec) { _node.Name = value } if value, ok := rc.mutation.Schema(); ok { - _spec.SetField(research.FieldSchema, field.TypeJSON, value) + _spec.SetField(research.FieldSchema, field.TypeBytes, value) _node.Schema = value } if nodes := rc.mutation.EventsIDs(); len(nodes) > 0 { diff --git a/internal/ent/research_update.go b/internal/ent/research_update.go index 3a75c48..f54051d 100644 --- a/internal/ent/research_update.go +++ b/internal/ent/research_update.go @@ -35,8 +35,8 @@ func (ru *ResearchUpdate) SetName(s string) *ResearchUpdate { } // SetSchema sets the "schema" field. -func (ru *ResearchUpdate) SetSchema(m map[string]interface{}) *ResearchUpdate { - ru.mutation.SetSchema(m) +func (ru *ResearchUpdate) SetSchema(b []byte) *ResearchUpdate { + ru.mutation.SetSchema(b) return ru } @@ -134,7 +134,7 @@ func (ru *ResearchUpdate) sqlSave(ctx context.Context) (n int, err error) { _spec.SetField(research.FieldName, field.TypeString, value) } if value, ok := ru.mutation.Schema(); ok { - _spec.SetField(research.FieldSchema, field.TypeJSON, value) + _spec.SetField(research.FieldSchema, field.TypeBytes, value) } if ru.mutation.EventsCleared() { edge := &sqlgraph.EdgeSpec{ @@ -208,8 +208,8 @@ func (ruo *ResearchUpdateOne) SetName(s string) *ResearchUpdateOne { } // SetSchema sets the "schema" field. -func (ruo *ResearchUpdateOne) SetSchema(m map[string]interface{}) *ResearchUpdateOne { - ruo.mutation.SetSchema(m) +func (ruo *ResearchUpdateOne) SetSchema(b []byte) *ResearchUpdateOne { + ruo.mutation.SetSchema(b) return ruo } @@ -337,7 +337,7 @@ func (ruo *ResearchUpdateOne) sqlSave(ctx context.Context) (_node *Research, err _spec.SetField(research.FieldName, field.TypeString, value) } if value, ok := ruo.mutation.Schema(); ok { - _spec.SetField(research.FieldSchema, field.TypeJSON, value) + _spec.SetField(research.FieldSchema, field.TypeBytes, value) } if ruo.mutation.EventsCleared() { edge := &sqlgraph.EdgeSpec{ diff --git a/internal/ent/schema/research.go b/internal/ent/schema/research.go index 7bf6fc2..360c50c 100644 --- a/internal/ent/schema/research.go +++ b/internal/ent/schema/research.go @@ -28,7 +28,7 @@ func (Research) Fields() []ent.Field { entgql.OrderField("ID"), ), field.String("name").MaxLen(64), - field.JSON("schema", map[string]any{}), + field.Bytes("schema"), } } diff --git a/internal/graph/defs/schema.graphqls b/internal/graph/defs/schema.graphqls index 048f189..65eb1ae 100644 --- a/internal/graph/defs/schema.graphqls +++ b/internal/graph/defs/schema.graphqls @@ -10,6 +10,7 @@ https://relay.dev/graphql/connections.htm#sec-Cursor scalar Cursor scalar Time scalar Any +scalar Void type Event implements Node { id: ID! @@ -142,7 +143,7 @@ type PageInfo { type Research implements Node { id: ID! name: String! - schema: Map! + schema: Any! } """ @@ -311,9 +312,21 @@ input LoginInput { turnstileResponse: String! } +input RequestPasswordResetInput { + email: String! + turnstileResponse: String! +} + +input ResetPasswordInput { + token: String! + password: String! +} + type Mutation { login(input: LoginInput!): User! createEvent(input: CreateEventInput!): Event! + requestPasswordReset(input: RequestPasswordResetInput!): Boolean! + resetPassword(input: ResetPasswordInput!): Boolean! createUser(input: CreateUserInput!): User! @admin } diff --git a/internal/graph/generated.go b/internal/graph/generated.go index aeb4510..93ce135 100644 --- a/internal/graph/generated.go +++ b/internal/graph/generated.go @@ -42,6 +42,7 @@ type Config struct { type ResolverRoot interface { Mutation() MutationResolver Query() QueryResolver + Research() ResearchResolver } type DirectiveRoot struct { @@ -81,9 +82,11 @@ type ComplexityRoot struct { } Mutation struct { - CreateEvent func(childComplexity int, input model.CreateEventInput) int - CreateUser func(childComplexity int, input model.CreateUserInput) int - Login func(childComplexity int, input model.LoginInput) int + CreateEvent func(childComplexity int, input model.CreateEventInput) int + CreateUser func(childComplexity int, input model.CreateUserInput) int + Login func(childComplexity int, input model.LoginInput) int + RequestPasswordReset func(childComplexity int, input model.RequestPasswordResetInput) int + ResetPassword func(childComplexity int, input model.ResetPasswordInput) int } PageInfo struct { @@ -132,6 +135,8 @@ type ComplexityRoot struct { type MutationResolver interface { Login(ctx context.Context, input model.LoginInput) (*ent.User, error) CreateEvent(ctx context.Context, input model.CreateEventInput) (*ent.Event, error) + RequestPasswordReset(ctx context.Context, input model.RequestPasswordResetInput) (bool, error) + ResetPassword(ctx context.Context, input model.ResetPasswordInput) (bool, error) CreateUser(ctx context.Context, input model.CreateUserInput) (*ent.User, error) } type QueryResolver interface { @@ -144,6 +149,9 @@ type QueryResolver interface { Research(ctx context.Context, id string) (*ent.Research, error) Researches(ctx context.Context, after *entgql.Cursor[string], first *int, before *entgql.Cursor[string], last *int, orderBy *ent.ResearchOrder) (*ent.ResearchConnection, error) } +type ResearchResolver interface { + Schema(ctx context.Context, obj *ent.Research) (interface{}, error) +} type executableSchema struct { resolvers ResolverRoot @@ -301,6 +309,30 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Mutation.Login(childComplexity, args["input"].(model.LoginInput)), true + case "Mutation.requestPasswordReset": + if e.complexity.Mutation.RequestPasswordReset == nil { + break + } + + args, err := ec.field_Mutation_requestPasswordReset_args(context.TODO(), rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.RequestPasswordReset(childComplexity, args["input"].(model.RequestPasswordResetInput)), true + + case "Mutation.resetPassword": + if e.complexity.Mutation.ResetPassword == nil { + break + } + + args, err := ec.field_Mutation_resetPassword_args(context.TODO(), rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.ResetPassword(childComplexity, args["input"].(model.ResetPasswordInput)), true + case "PageInfo.endCursor": if e.complexity.PageInfo.EndCursor == nil { break @@ -517,7 +549,9 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler { ec.unmarshalInputEventOrder, ec.unmarshalInputGroupCountInput, ec.unmarshalInputLoginInput, + ec.unmarshalInputRequestPasswordResetInput, ec.unmarshalInputResearchOrder, + ec.unmarshalInputResetPasswordInput, ) first := true @@ -694,6 +728,36 @@ func (ec *executionContext) field_Mutation_login_args(ctx context.Context, rawAr return args, nil } +func (ec *executionContext) field_Mutation_requestPasswordReset_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { + var err error + args := map[string]interface{}{} + var arg0 model.RequestPasswordResetInput + if tmp, ok := rawArgs["input"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("input")) + arg0, err = ec.unmarshalNRequestPasswordResetInput2exusiaiᚗdevᚋroguestatsᚑbackendᚋinternalᚋmodelᚐRequestPasswordResetInput(ctx, tmp) + if err != nil { + return nil, err + } + } + args["input"] = arg0 + return args, nil +} + +func (ec *executionContext) field_Mutation_resetPassword_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { + var err error + args := map[string]interface{}{} + var arg0 model.ResetPasswordInput + if tmp, ok := rawArgs["input"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("input")) + arg0, err = ec.unmarshalNResetPasswordInput2exusiaiᚗdevᚋroguestatsᚑbackendᚋinternalᚋmodelᚐResetPasswordInput(ctx, tmp) + if err != nil { + return nil, err + } + } + args["input"] = arg0 + return args, nil +} + func (ec *executionContext) field_Query___type_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} @@ -1766,6 +1830,116 @@ func (ec *executionContext) fieldContext_Mutation_createEvent(ctx context.Contex return fc, nil } +func (ec *executionContext) _Mutation_requestPasswordReset(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Mutation_requestPasswordReset(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Mutation().RequestPasswordReset(rctx, fc.Args["input"].(model.RequestPasswordResetInput)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(bool) + fc.Result = res + return ec.marshalNBoolean2bool(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Mutation_requestPasswordReset(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_requestPasswordReset_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Mutation_resetPassword(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Mutation_resetPassword(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Mutation().ResetPassword(rctx, fc.Args["input"].(model.ResetPasswordInput)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(bool) + fc.Result = res + return ec.marshalNBoolean2bool(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Mutation_resetPassword(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_resetPassword_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + func (ec *executionContext) _Mutation_createUser(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Mutation_createUser(ctx, field) if err != nil { @@ -2723,7 +2897,7 @@ func (ec *executionContext) _Research_schema(ctx context.Context, field graphql. }() resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { ctx = rctx // use context from middleware stack in children - return obj.Schema, nil + return ec.resolvers.Research().Schema(rctx, obj) }) if err != nil { ec.Error(ctx, err) @@ -2735,19 +2909,19 @@ func (ec *executionContext) _Research_schema(ctx context.Context, field graphql. } return graphql.Null } - res := resTmp.(map[string]interface{}) + res := resTmp.(interface{}) fc.Result = res - return ec.marshalNMap2map(ctx, field.Selections, res) + return ec.marshalNAny2interface(ctx, field.Selections, res) } func (ec *executionContext) fieldContext_Research_schema(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { fc = &graphql.FieldContext{ Object: "Research", Field: field, - IsMethod: false, - IsResolver: false, + IsMethod: true, + IsResolver: true, Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { - return nil, errors.New("field of type Map does not have child fields") + return nil, errors.New("field of type Any does not have child fields") }, } return fc, nil @@ -5218,6 +5392,44 @@ func (ec *executionContext) unmarshalInputLoginInput(ctx context.Context, obj in return it, nil } +func (ec *executionContext) unmarshalInputRequestPasswordResetInput(ctx context.Context, obj interface{}) (model.RequestPasswordResetInput, error) { + var it model.RequestPasswordResetInput + asMap := map[string]interface{}{} + for k, v := range obj.(map[string]interface{}) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"email", "turnstileResponse"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "email": + var err error + + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("email")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.Email = data + case "turnstileResponse": + var err error + + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("turnstileResponse")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.TurnstileResponse = data + } + } + + return it, nil +} + func (ec *executionContext) unmarshalInputResearchOrder(ctx context.Context, obj interface{}) (ent.ResearchOrder, error) { var it ent.ResearchOrder asMap := map[string]interface{}{} @@ -5260,6 +5472,44 @@ func (ec *executionContext) unmarshalInputResearchOrder(ctx context.Context, obj return it, nil } +func (ec *executionContext) unmarshalInputResetPasswordInput(ctx context.Context, obj interface{}) (model.ResetPasswordInput, error) { + var it model.ResetPasswordInput + asMap := map[string]interface{}{} + for k, v := range obj.(map[string]interface{}) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"token", "password"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "token": + var err error + + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("token")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.Token = data + case "password": + var err error + + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("password")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.Password = data + } + } + + return it, nil +} + // endregion **************************** input.gotpl ***************************** // region ************************** interface.gotpl *************************** @@ -5626,6 +5876,20 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) if out.Values[i] == graphql.Null { out.Invalids++ } + case "requestPasswordReset": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_requestPasswordReset(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "resetPassword": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_resetPassword(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } case "createUser": out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { return ec._Mutation_createUser(ctx, field) @@ -5932,18 +6196,49 @@ func (ec *executionContext) _Research(ctx context.Context, sel ast.SelectionSet, case "id": out.Values[i] = ec._Research_id(ctx, field, obj) if out.Values[i] == graphql.Null { - out.Invalids++ + atomic.AddUint32(&out.Invalids, 1) } case "name": out.Values[i] = ec._Research_name(ctx, field, obj) if out.Values[i] == graphql.Null { - out.Invalids++ + atomic.AddUint32(&out.Invalids, 1) } case "schema": - out.Values[i] = ec._Research_schema(ctx, field, obj) - if out.Values[i] == graphql.Null { - out.Invalids++ + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Research_schema(ctx, field, obj) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + if field.Deferrable != nil { + dfs, ok := deferred[field.Deferrable.Label] + di := 0 + if ok { + dfs.AddField(field) + di = len(dfs.Values) - 1 + } else { + dfs = graphql.NewFieldSet([]graphql.CollectedField{field}) + deferred[field.Deferrable.Label] = dfs + } + dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler { + return innerFunc(ctx, dfs) + }) + + // don't run the out.Concurrently() call below + out.Values[i] = graphql.Null + continue } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) default: panic("unknown field " + strconv.Quote(field.Name)) } @@ -6801,6 +7096,11 @@ func (ec *executionContext) marshalNPageInfo2entgoᚗioᚋcontribᚋentgqlᚐPag return ec._PageInfo(ctx, sel, &v) } +func (ec *executionContext) unmarshalNRequestPasswordResetInput2exusiaiᚗdevᚋroguestatsᚑbackendᚋinternalᚋmodelᚐRequestPasswordResetInput(ctx context.Context, v interface{}) (model.RequestPasswordResetInput, error) { + res, err := ec.unmarshalInputRequestPasswordResetInput(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + func (ec *executionContext) marshalNResearch2ᚖexusiaiᚗdevᚋroguestatsᚑbackendᚋinternalᚋentᚐResearch(ctx context.Context, sel ast.SelectionSet, v *ent.Research) graphql.Marshaler { if v == nil { if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { @@ -6895,6 +7195,11 @@ func (ec *executionContext) marshalNResearchOrderField2ᚖexusiaiᚗdevᚋrogues return v } +func (ec *executionContext) unmarshalNResetPasswordInput2exusiaiᚗdevᚋroguestatsᚑbackendᚋinternalᚋmodelᚐResetPasswordInput(ctx context.Context, v interface{}) (model.ResetPasswordInput, error) { + res, err := ec.unmarshalInputResetPasswordInput(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + func (ec *executionContext) unmarshalNString2string(ctx context.Context, v interface{}) (string, error) { res, err := graphql.UnmarshalString(v) return res, graphql.ErrorOnPath(ctx, err) diff --git a/internal/graph/schema.resolvers.go b/internal/graph/schema.resolvers.go index 4d222b4..e9c5fba 100644 --- a/internal/graph/schema.resolvers.go +++ b/internal/graph/schema.resolvers.go @@ -6,6 +6,7 @@ package graph import ( "context" + "encoding/json" "entgo.io/contrib/entgql" "exusiai.dev/roguestats-backend/internal/ent" @@ -22,6 +23,16 @@ func (r *mutationResolver) CreateEvent(ctx context.Context, input model.CreateEv return r.EventService.CreateEventFromInput(ctx, input) } +// RequestPasswordReset is the resolver for the requestPasswordReset field. +func (r *mutationResolver) RequestPasswordReset(ctx context.Context, input model.RequestPasswordResetInput) (bool, error) { + return r.AuthService.RequestPasswordReset(ctx, input) +} + +// ResetPassword is the resolver for the resetPassword field. +func (r *mutationResolver) ResetPassword(ctx context.Context, input model.ResetPasswordInput) (bool, error) { + return r.AuthService.ResetPassword(ctx, input) +} + // CreateUser is the resolver for the createUser field. func (r *mutationResolver) CreateUser(ctx context.Context, input model.CreateUserInput) (*ent.User, error) { return r.AuthService.CreateUser(ctx, input) @@ -73,11 +84,20 @@ func (r *queryResolver) Researches(ctx context.Context, after *entgql.Cursor[str ) } +// Schema is the resolver for the schema field. +func (r *researchResolver) Schema(ctx context.Context, obj *ent.Research) (interface{}, error) { + return json.RawMessage(obj.Schema), nil +} + // Mutation returns MutationResolver implementation. func (r *Resolver) Mutation() MutationResolver { return &mutationResolver{r} } // Query returns QueryResolver implementation. func (r *Resolver) Query() QueryResolver { return &queryResolver{r} } +// Research returns ResearchResolver implementation. +func (r *Resolver) Research() ResearchResolver { return &researchResolver{r} } + type mutationResolver struct{ *Resolver } type queryResolver struct{ *Resolver } +type researchResolver struct{ *Resolver } diff --git a/internal/infra/0module.go b/internal/infra/0module.go index 073fb4a..b22f231 100644 --- a/internal/infra/0module.go +++ b/internal/infra/0module.go @@ -7,5 +7,5 @@ import ( ) func Module() fx.Option { - return fx.Module("infra", db.Module(), fx.Provide(Resend)) + return fx.Module("infra", db.Module(), fx.Provide(Resend), fx.Provide(Redis)) } diff --git a/internal/infra/db/db.go b/internal/infra/db/db.go index c8c81d8..5f3d5e2 100644 --- a/internal/infra/db/db.go +++ b/internal/infra/db/db.go @@ -20,5 +20,5 @@ func New(conf *appconfig.Config) *ent.Client { if err := client.Schema.Create(context.Background()); err != nil { log.Fatal().Err(err).Msg("failed creating schema resources") } - return client.Debug() + return client } diff --git a/internal/infra/redis.go b/internal/infra/redis.go new file mode 100644 index 0000000..d42d2fd --- /dev/null +++ b/internal/infra/redis.go @@ -0,0 +1,33 @@ +package infra + +import ( + "context" + "time" + + "github.com/redis/go-redis/v9" + "github.com/rs/zerolog/log" + + "exusiai.dev/roguestats-backend/internal/app/appconfig" +) + +func Redis(conf *appconfig.Config) (*redis.Client, error) { + u, err := redis.ParseURL(conf.RedisURL) + if err != nil { + log.Error().Err(err).Msg("infra: redis: failed to parse redis url") + return nil, err + } + + // Open a Redis Client + client := redis.NewClient(u) + + // check redis connection + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + ping := client.Ping(ctx) + if ping.Err() != nil { + log.Error().Err(ping.Err()).Msg("infra: redis: failed to ping database") + return nil, ping.Err() + } + + return client, nil +} diff --git a/internal/model/models_gen.go b/internal/model/models_gen.go index 99c4227..929219f 100644 --- a/internal/model/models_gen.go +++ b/internal/model/models_gen.go @@ -29,3 +29,13 @@ type GroupCountResult struct { Results []*CategoryCount `json:"results"` Total int `json:"total"` } + +type RequestPasswordResetInput struct { + Email string `json:"email"` + TurnstileResponse string `json:"turnstileResponse"` +} + +type ResetPasswordInput struct { + Token string `json:"token"` + Password string `json:"password"` +} diff --git a/internal/service/auth.go b/internal/service/auth.go index 6e1a518..615da1b 100644 --- a/internal/service/auth.go +++ b/internal/service/auth.go @@ -2,12 +2,13 @@ package service import ( "context" - "crypto/rand" - "encoding/base64" "time" + "github.com/dchest/uniuri" "github.com/pkg/errors" + "github.com/redis/go-redis/v9" "github.com/vektah/gqlparser/v2/gqlerror" + passwordvalidator "github.com/wagslane/go-password-validator" "go.uber.org/fx" "golang.org/x/crypto/bcrypt" @@ -17,6 +18,7 @@ import ( "exusiai.dev/roguestats-backend/internal/ent" "exusiai.dev/roguestats-backend/internal/ent/user" "exusiai.dev/roguestats-backend/internal/model" + "exusiai.dev/roguestats-backend/internal/x/rediskey" ) type Auth struct { @@ -24,6 +26,7 @@ type Auth struct { Config *appconfig.Config Ent *ent.Client + Redis *redis.Client JWT JWT Turnstile Turnstile MailService Mail @@ -32,7 +35,7 @@ type Auth struct { func (s Auth) AuthByLoginInput(ctx context.Context, args model.LoginInput) (*ent.User, error) { err := s.Turnstile.Verify(ctx, args.TurnstileResponse, "login") if err != nil { - return nil, gqlerror.Errorf("captcha verification failed: invalid turnstile response") + return nil, err } user, err := s.Ent.User.Query(). @@ -73,12 +76,7 @@ func (s Auth) AuthByToken(ctx context.Context, token string) (*ent.User, error) } func (s Auth) CreateUser(ctx context.Context, args model.CreateUserInput) (*ent.User, error) { - var randomBytes [16]byte - _, err := rand.Read(randomBytes[:]) - if err != nil { - return nil, err - } - randomString := base64.RawURLEncoding.EncodeToString(randomBytes[:]) + randomString := uniuri.NewLen(24) hashedPassword, err := bcrypt.GenerateFromPassword([]byte(randomString), 12) if err != nil { @@ -140,3 +138,81 @@ func (s Auth) SetUserToken(ctx context.Context, user *ent.User) error { return nil } + +func (s Auth) RequestPasswordReset(ctx context.Context, input model.RequestPasswordResetInput) (bool, error) { + err := s.Turnstile.Verify(ctx, input.TurnstileResponse, "reset-password") + if err != nil { + return false, err + } + user, err := s.Ent.User.Query(). + Where(user.Email(input.Email)). + First(ctx) + if err != nil { + return false, err + } + + resetToken := user.ID + "_" + uniuri.NewLen(32) + + err = s.Redis.Set(ctx, rediskey.ResetToken(resetToken), user.ID, s.Config.PasswordResetTokenTTL).Err() + if err != nil { + return false, err + } + + rendered, err := blob.RenderPair("password-reset", map[string]any{ + "Username": user.Name, + "Token": resetToken, + "TokenTTL": s.Config.PasswordResetTokenTTL.String(), + }) + if err != nil { + return false, err + } + + _, err = s.MailService.Send(&SendEmailRequest{ + To: []string{user.Email}, + Subject: "你的 RogueStats 登录信息重置请求已就绪", + Html: rendered.HTML, + Text: rendered.Text, + }) + if err != nil { + return false, err + } + + return true, nil +} + +func (s Auth) ResetPassword(ctx context.Context, input model.ResetPasswordInput) (bool, error) { + cmd := s.Redis.Get(ctx, rediskey.ResetToken(input.Token)) + if cmd.Err() != nil { + return false, gqlerror.Errorf("invalid password reset token: the token is either expired, invalid or has been used") + } + + user, err := s.Ent.User.Query(). + Where(user.ID(cmd.Val())). + First(ctx) + if err != nil { + return false, err + } + + err = passwordvalidator.Validate(input.Password, 60) + if err != nil { + return false, err + } + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(input.Password), 12) + if err != nil { + return false, err + } + + _, err = s.Ent.User.UpdateOneID(user.ID). + SetCredential(string(hashedPassword)). + Save(ctx) + if err != nil { + return false, err + } + + if s.Redis.Del(ctx, rediskey.ResetToken(input.Token)).Err() != nil { + return false, err + } + + return true, nil +} diff --git a/internal/service/event.go b/internal/service/event.go index 21af664..e403c34 100644 --- a/internal/service/event.go +++ b/internal/service/event.go @@ -3,7 +3,6 @@ package service import ( "context" "database/sql" - "encoding/json" "fmt" "reflect" "sort" @@ -44,11 +43,7 @@ func (s Event) CreateEventFromInput(ctx context.Context, input model.CreateEvent } // validate event json - schema, err := json.Marshal(research.Schema) - if err != nil { - return nil, err - } - sch, err := jsonschema.CompileString("schema.json", string(schema)) + sch, err := jsonschema.CompileString("schema.json", string(research.Schema)) if err != nil { return nil, err } diff --git a/internal/x/rediskey/rediskey.go b/internal/x/rediskey/rediskey.go new file mode 100644 index 0000000..217f753 --- /dev/null +++ b/internal/x/rediskey/rediskey.go @@ -0,0 +1,9 @@ +package rediskey + +func K(suffix string) string { + return "roguestats|" + suffix +} + +func ResetToken(userID string) string { + return K("reset_token|" + userID) +} diff --git a/internal/research/schema/battle.schema.json b/schema/rsc_01h8yfh5y5vff7sss16ra735rc.battle.json similarity index 95% rename from internal/research/schema/battle.schema.json rename to schema/rsc_01h8yfh5y5vff7sss16ra735rc.battle.json index b6429d1..2d6b441 100644 --- a/internal/research/schema/battle.schema.json +++ b/schema/rsc_01h8yfh5y5vff7sss16ra735rc.battle.json @@ -174,9 +174,29 @@ "type": "array", "minItems": 0, "maxItems": 2, + "x-nullableArray": { + "options": [ + { + "value": "not_reporting", + "label": "不汇报此数据项、或藏品有电台" + }, + { + "value": "reporting_0", + "label": "未掉落招募券" + }, + { + "value": "reporting_1", + "label": "掉落了招募券:仅有 1 个「掉落框」" + }, + { + "value": "reporting_2", + "label": "掉落了招募券:有 2 个「掉落框」" + } + ] + }, "items": { "type": "array", - "minItems": 0, + "minItems": 1, "maxItems": 2, "uniqueItems": true, "items": { @@ -279,7 +299,7 @@ "title": "掉落密文板", "description": "若藏品有伶牙毁林者,请留空不填;请同时把两个选项都选上", "type": "array", - "minItems": 1, + "minItems": 0, "maxItems": 3, "uniqueItems": true, "items": { diff --git a/internal/research/schema/incident.schema.json b/schema/rsc_01h8yfh5y9mf4ws7ehf1q9n9n5.incident.json similarity index 100% rename from internal/research/schema/incident.schema.json rename to schema/rsc_01h8yfh5y9mf4ws7ehf1q9n9n5.incident.json diff --git a/internal/research/schema/portal.schema.json b/schema/rsc_01h8yfh5ycezqx0pkgw0t72wcg.portal.json similarity index 100% rename from internal/research/schema/portal.schema.json rename to schema/rsc_01h8yfh5ycezqx0pkgw0t72wcg.portal.json index f1c06ac..6941cbd 100644 --- a/internal/research/schema/portal.schema.json +++ b/schema/rsc_01h8yfh5ycezqx0pkgw0t72wcg.portal.json @@ -4,18 +4,18 @@ "type": "object", "additionalProperties": false, "properties": { - "floor": { - "title": "层数", - "type": "integer", - "maximum": 6, - "minimum": 1 - }, "grade": { "title": "难度", "type": "integer", "maximum": 15, "minimum": 0 }, + "floor": { + "title": "层数", + "type": "integer", + "maximum": 6, + "minimum": 1 + }, "layout": { "title": "节点布局", "description": "“战斗”代表只有战斗节点,“事件”代表完全无战斗节点,“混合”代表二者皆有。后面的每个数字代表每一列有多少个节点。", diff --git a/internal/research/schema/rest.schema.json b/schema/rsc_01h8yfh5yg0wergmjhewagsrq2.rest.json similarity index 100% rename from internal/research/schema/rest.schema.json rename to schema/rsc_01h8yfh5yg0wergmjhewagsrq2.rest.json