From b2a8fd09f97e367dc5b7b7231b7f5bbe902e1ad6 Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Fri, 21 Feb 2020 16:50:50 +0100 Subject: [PATCH] Work towards subscriptions - Replace chi with gorilla/mux - Write custom CORS rules - Start on notification subscriptions --- api/.vscode/launch.json | 2 +- api/go.mod | 1 + api/go.sum | 7 + api/graphql/generated.go | 524 ++++++++++++++++++++++- api/graphql/models/generated.go | 51 +++ api/graphql/notification/Notification.go | 64 +++ api/graphql/resolvers/notification.go | 27 ++ api/graphql/resolvers/root.go | 10 + api/graphql/schema.graphql | 19 + api/routes/photos.go | 13 +- api/{server => }/server.go | 55 +-- api/server/cors_middleware.go | 48 +++ 12 files changed, 787 insertions(+), 34 deletions(-) create mode 100644 api/graphql/notification/Notification.go create mode 100644 api/graphql/resolvers/notification.go rename api/{server => }/server.go (53%) create mode 100644 api/server/cors_middleware.go diff --git a/api/.vscode/launch.json b/api/.vscode/launch.json index 5e7748db..a4b3b9f1 100644 --- a/api/.vscode/launch.json +++ b/api/.vscode/launch.json @@ -9,7 +9,7 @@ "type": "go", "request": "launch", "mode": "auto", - "program": "${workspaceRoot}/server/server.go", + "program": "${workspaceRoot}/server.go", "cwd": "${workspaceRoot}", "env": {}, "args": [] diff --git a/api/go.mod b/api/go.mod index 8496097f..858f82ac 100644 --- a/api/go.mod +++ b/api/go.mod @@ -8,6 +8,7 @@ require ( github.com/go-chi/cors v1.0.0 github.com/go-sql-driver/mysql v1.5.0 github.com/golang-migrate/migrate v3.5.4+incompatible + github.com/gorilla/mux v1.7.4 github.com/h2non/filetype v1.0.10 github.com/joho/godotenv v1.3.0 github.com/lib/pq v1.3.0 diff --git a/api/go.sum b/api/go.sum index 15c6e782..aa482c91 100644 --- a/api/go.sum +++ b/api/go.sum @@ -4,6 +4,7 @@ github.com/agnivade/levenshtein v1.0.1 h1:3oJU7J3FGFmyhn8KHjmVaZCN5hxTr7GxgRue+s github.com/agnivade/levenshtein v1.0.1/go.mod h1:CURSv5d9Uaml+FovSIICkLbAUZ9S4RqaHDIsdSBg7lM= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= 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/go-chi/chi v3.3.2+incompatible h1:uQNcQN3NsV1j4ANsPh42P4ew4t6rnRbJb8frvpp31qQ= github.com/go-chi/chi v3.3.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= @@ -15,7 +16,10 @@ github.com/gogo/protobuf v1.0.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7a github.com/golang-migrate/migrate v3.5.4+incompatible h1:R7OzwvCJTCgwapPCiX6DyBiu2czIUMDCB118gFTKTUA= github.com/golang-migrate/migrate v3.5.4+incompatible/go.mod h1:IsVUlFN5puWOmXrqjgGUfIRIbU7mr8oNBE2tyERd9Wk= github.com/gorilla/context v0.0.0-20160226214623-1ea25387ff6f/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= +github.com/gorilla/mux v1.6.1 h1:KOwqsTYZdeuMacU7CxjMNYEKeBvLbxW+psodrbcEa3A= github.com/gorilla/mux v1.6.1/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= +github.com/gorilla/mux v1.7.4 h1:VuZ8uybHlWmqV03+zRzdwKL4tUnIp1MAQtp1mIFE1bc= +github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/websocket v1.2.0 h1:VJtLvh6VQym50czpZzx07z/kw9EgAxI3x1ZB8taTMQQ= github.com/gorilla/websocket v1.2.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/h2non/filetype v1.0.10 h1:z+SJfnL6thYJ9kAST+6nPRXp1lMxnOVbMZHNYHMar0s= @@ -30,6 +34,7 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/lib/pq v1.3.0 h1:/qkRGz8zljWiDcFvgpwUpwIAPu3r07TDvs3Rws+o/pU= github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/mitchellh/mapstructure v0.0.0-20180203102830-a4e142e9c047 h1:zCoDWFD5nrJJVjbXiDZcVhOBSzKn3o9LgRLLMRNuru8= github.com/mitchellh/mapstructure v0.0.0-20180203102830-a4e142e9c047/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/nf/cr2 v0.0.0-20180623103828-4699471a17ed h1:QP63yO3XEt8tJ1DgBsNjbOyBGjy2eHy9ITK4Eisr9rg= github.com/nf/cr2 v0.0.0-20180623103828-4699471a17ed/go.mod h1:HazDB3gS/i//QXMMRmTAV7Ni9gAi4mDNTH2HjZ5aVgU= @@ -39,6 +44,7 @@ github.com/opentracing/basictracer-go v1.0.0/go.mod h1:QfBfYuafItcjQuMwinw9GhYKw github.com/opentracing/opentracing-go v1.0.2/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.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/rs/cors v1.6.0 h1:G9tHG9lebljV9mfp9SNPDL36nCDxmo3zTlAf1YgvzmI= github.com/rs/cors v1.6.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU= @@ -51,6 +57,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/urfave/cli v1.20.0 h1:fDqGv3UG/4jbVl/QkFwEdddtEDjh/5Ov6X+0B/3bPaw= github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= diff --git a/api/graphql/generated.go b/api/graphql/generated.go index 314438c3..2f55bff5 100644 --- a/api/graphql/generated.go +++ b/api/graphql/generated.go @@ -7,6 +7,7 @@ import ( "context" "errors" "fmt" + "io" "strconv" "sync" "sync/atomic" @@ -42,6 +43,7 @@ type ResolverRoot interface { Photo() PhotoResolver Query() QueryResolver ShareToken() ShareTokenResolver + Subscription() SubscriptionResolver } type DirectiveRoot struct { @@ -81,6 +83,16 @@ type ComplexityRoot struct { UpdateUser func(childComplexity int, id int, username *string, rootPath *string, admin *bool) int } + Notification struct { + Content func(childComplexity int) int + Header func(childComplexity int) int + Key func(childComplexity int) int + Negative func(childComplexity int) int + Positive func(childComplexity int) int + Progress func(childComplexity int) int + Type func(childComplexity int) int + } + Photo struct { Album func(childComplexity int) int Downloads func(childComplexity int) int @@ -149,6 +161,10 @@ type ComplexityRoot struct { InitialSetup func(childComplexity int) int } + Subscription struct { + Notification func(childComplexity int) int + } + User struct { Admin func(childComplexity int) int ID func(childComplexity int) int @@ -203,6 +219,9 @@ type ShareTokenResolver interface { Album(ctx context.Context, obj *models.ShareToken) (*models.Album, error) Photo(ctx context.Context, obj *models.ShareToken) (*models.Photo, error) } +type SubscriptionResolver interface { + Notification(ctx context.Context) (<-chan *models.Notification, error) +} type executableSchema struct { resolvers ResolverRoot @@ -440,6 +459,55 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Mutation.UpdateUser(childComplexity, args["id"].(int), args["username"].(*string), args["rootPath"].(*string), args["admin"].(*bool)), true + case "Notification.content": + if e.complexity.Notification.Content == nil { + break + } + + return e.complexity.Notification.Content(childComplexity), true + + case "Notification.header": + if e.complexity.Notification.Header == nil { + break + } + + return e.complexity.Notification.Header(childComplexity), true + + case "Notification.key": + if e.complexity.Notification.Key == nil { + break + } + + return e.complexity.Notification.Key(childComplexity), true + + case "Notification.negative": + if e.complexity.Notification.Negative == nil { + break + } + + return e.complexity.Notification.Negative(childComplexity), true + + case "Notification.positive": + if e.complexity.Notification.Positive == nil { + break + } + + return e.complexity.Notification.Positive(childComplexity), true + + case "Notification.progress": + if e.complexity.Notification.Progress == nil { + break + } + + return e.complexity.Notification.Progress(childComplexity), true + + case "Notification.type": + if e.complexity.Notification.Type == nil { + break + } + + return e.complexity.Notification.Type(childComplexity), true + case "Photo.album": if e.complexity.Photo.Album == nil { break @@ -778,6 +846,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.SiteInfo.InitialSetup(childComplexity), true + case "Subscription.notification": + if e.complexity.Subscription.Notification == nil { + break + } + + return e.complexity.Subscription.Notification(childComplexity), true + case "User.admin": if e.complexity.User.Admin == nil { break @@ -845,7 +920,36 @@ func (e *executableSchema) Mutation(ctx context.Context, op *ast.OperationDefini } func (e *executableSchema) Subscription(ctx context.Context, op *ast.OperationDefinition) func() *graphql.Response { - return graphql.OneShot(graphql.ErrorResponse(ctx, "subscriptions are not supported")) + ec := executionContext{graphql.GetRequestContext(ctx), e} + + next := ec._Subscription(ctx, op.SelectionSet) + if ec.Errors != nil { + return graphql.OneShot(&graphql.Response{Data: []byte("null"), Errors: ec.Errors}) + } + + var buf bytes.Buffer + return func() *graphql.Response { + buf := ec.RequestMiddleware(ctx, func(ctx context.Context) []byte { + buf.Reset() + data := next() + + if data == nil { + return nil + } + data.MarshalGQL(&buf) + return buf.Bytes() + }) + + if buf == nil { + return nil + } + + return &graphql.Response{ + Data: buf, + Errors: ec.Errors, + Extensions: ec.Extensions, + } + } } type executionContext struct { @@ -955,6 +1059,25 @@ type Mutation { deleteUser(id: Int!): User @isAdmin } +type Subscription { + notification: Notification! +} + +enum NotificationType { + Message + Progress +} + +type Notification { + key: String! + type: NotificationType! + header: String! + content: String! + progress: Float + positive: Boolean! + negative: Boolean! +} + type AuthorizeResult { success: Boolean! status: String! @@ -2506,6 +2629,262 @@ func (ec *executionContext) _Mutation_deleteUser(ctx context.Context, field grap return ec.marshalOUser2ᚖgithubᚗcomᚋviktorstrateᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐUser(ctx, field.Selections, res) } +func (ec *executionContext) _Notification_key(ctx context.Context, field graphql.CollectedField, obj *models.Notification) (ret graphql.Marshaler) { + ctx = ec.Tracer.StartFieldExecution(ctx, field) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + ec.Tracer.EndFieldExecution(ctx) + }() + rctx := &graphql.ResolverContext{ + Object: "Notification", + Field: field, + Args: nil, + IsMethod: false, + } + ctx = graphql.WithResolverContext(ctx, rctx) + ctx = ec.Tracer.StartFieldResolverExecution(ctx, rctx) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Key, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !ec.HasError(rctx) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(string) + rctx.Result = res + ctx = ec.Tracer.StartFieldChildExecution(ctx) + return ec.marshalNString2string(ctx, field.Selections, res) +} + +func (ec *executionContext) _Notification_type(ctx context.Context, field graphql.CollectedField, obj *models.Notification) (ret graphql.Marshaler) { + ctx = ec.Tracer.StartFieldExecution(ctx, field) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + ec.Tracer.EndFieldExecution(ctx) + }() + rctx := &graphql.ResolverContext{ + Object: "Notification", + Field: field, + Args: nil, + IsMethod: false, + } + ctx = graphql.WithResolverContext(ctx, rctx) + ctx = ec.Tracer.StartFieldResolverExecution(ctx, rctx) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Type, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !ec.HasError(rctx) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(models.NotificationType) + rctx.Result = res + ctx = ec.Tracer.StartFieldChildExecution(ctx) + return ec.marshalNNotificationType2githubᚗcomᚋviktorstrateᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐNotificationType(ctx, field.Selections, res) +} + +func (ec *executionContext) _Notification_header(ctx context.Context, field graphql.CollectedField, obj *models.Notification) (ret graphql.Marshaler) { + ctx = ec.Tracer.StartFieldExecution(ctx, field) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + ec.Tracer.EndFieldExecution(ctx) + }() + rctx := &graphql.ResolverContext{ + Object: "Notification", + Field: field, + Args: nil, + IsMethod: false, + } + ctx = graphql.WithResolverContext(ctx, rctx) + ctx = ec.Tracer.StartFieldResolverExecution(ctx, rctx) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Header, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !ec.HasError(rctx) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(string) + rctx.Result = res + ctx = ec.Tracer.StartFieldChildExecution(ctx) + return ec.marshalNString2string(ctx, field.Selections, res) +} + +func (ec *executionContext) _Notification_content(ctx context.Context, field graphql.CollectedField, obj *models.Notification) (ret graphql.Marshaler) { + ctx = ec.Tracer.StartFieldExecution(ctx, field) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + ec.Tracer.EndFieldExecution(ctx) + }() + rctx := &graphql.ResolverContext{ + Object: "Notification", + Field: field, + Args: nil, + IsMethod: false, + } + ctx = graphql.WithResolverContext(ctx, rctx) + ctx = ec.Tracer.StartFieldResolverExecution(ctx, rctx) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Content, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !ec.HasError(rctx) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(string) + rctx.Result = res + ctx = ec.Tracer.StartFieldChildExecution(ctx) + return ec.marshalNString2string(ctx, field.Selections, res) +} + +func (ec *executionContext) _Notification_progress(ctx context.Context, field graphql.CollectedField, obj *models.Notification) (ret graphql.Marshaler) { + ctx = ec.Tracer.StartFieldExecution(ctx, field) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + ec.Tracer.EndFieldExecution(ctx) + }() + rctx := &graphql.ResolverContext{ + Object: "Notification", + Field: field, + Args: nil, + IsMethod: false, + } + ctx = graphql.WithResolverContext(ctx, rctx) + ctx = ec.Tracer.StartFieldResolverExecution(ctx, rctx) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Progress, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*float64) + rctx.Result = res + ctx = ec.Tracer.StartFieldChildExecution(ctx) + return ec.marshalOFloat2ᚖfloat64(ctx, field.Selections, res) +} + +func (ec *executionContext) _Notification_positive(ctx context.Context, field graphql.CollectedField, obj *models.Notification) (ret graphql.Marshaler) { + ctx = ec.Tracer.StartFieldExecution(ctx, field) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + ec.Tracer.EndFieldExecution(ctx) + }() + rctx := &graphql.ResolverContext{ + Object: "Notification", + Field: field, + Args: nil, + IsMethod: false, + } + ctx = graphql.WithResolverContext(ctx, rctx) + ctx = ec.Tracer.StartFieldResolverExecution(ctx, rctx) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Positive, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !ec.HasError(rctx) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(bool) + rctx.Result = res + ctx = ec.Tracer.StartFieldChildExecution(ctx) + return ec.marshalNBoolean2bool(ctx, field.Selections, res) +} + +func (ec *executionContext) _Notification_negative(ctx context.Context, field graphql.CollectedField, obj *models.Notification) (ret graphql.Marshaler) { + ctx = ec.Tracer.StartFieldExecution(ctx, field) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + ec.Tracer.EndFieldExecution(ctx) + }() + rctx := &graphql.ResolverContext{ + Object: "Notification", + Field: field, + Args: nil, + IsMethod: false, + } + ctx = graphql.WithResolverContext(ctx, rctx) + ctx = ec.Tracer.StartFieldResolverExecution(ctx, rctx) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Negative, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !ec.HasError(rctx) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(bool) + rctx.Result = res + ctx = ec.Tracer.StartFieldChildExecution(ctx) + return ec.marshalNBoolean2bool(ctx, field.Selections, res) +} + func (ec *executionContext) _Photo_id(ctx context.Context, field graphql.CollectedField, obj *models.Photo) (ret graphql.Marshaler) { ctx = ec.Tracer.StartFieldExecution(ctx, field) defer func() { @@ -4220,6 +4599,52 @@ func (ec *executionContext) _SiteInfo_initialSetup(ctx context.Context, field gr return ec.marshalNBoolean2bool(ctx, field.Selections, res) } +func (ec *executionContext) _Subscription_notification(ctx context.Context, field graphql.CollectedField) (ret func() graphql.Marshaler) { + ctx = ec.Tracer.StartFieldExecution(ctx, field) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = nil + } + ec.Tracer.EndFieldExecution(ctx) + }() + rctx := &graphql.ResolverContext{ + Object: "Subscription", + Field: field, + Args: nil, + IsMethod: true, + } + ctx = graphql.WithResolverContext(ctx, rctx) + ctx = ec.Tracer.StartFieldResolverExecution(ctx, rctx) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Subscription().Notification(rctx) + }) + if err != nil { + ec.Error(ctx, err) + return nil + } + if resTmp == nil { + if !ec.HasError(rctx) { + ec.Errorf(ctx, "must not be null") + } + return nil + } + return func() graphql.Marshaler { + res, ok := <-resTmp.(<-chan *models.Notification) + if !ok { + return nil + } + return graphql.WriterFunc(func(w io.Writer) { + w.Write([]byte{'{'}) + graphql.MarshalString(field.Alias).MarshalGQL(w) + w.Write([]byte{':'}) + ec.marshalNNotification2ᚖgithubᚗcomᚋviktorstrateᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐNotification(ctx, field.Selections, res).MarshalGQL(w) + w.Write([]byte{'}'}) + }) + } +} + func (ec *executionContext) _User_id(ctx context.Context, field graphql.CollectedField, obj *models.User) (ret graphql.Marshaler) { ctx = ec.Tracer.StartFieldExecution(ctx, field) defer func() { @@ -5789,6 +6214,60 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) return out } +var notificationImplementors = []string{"Notification"} + +func (ec *executionContext) _Notification(ctx context.Context, sel ast.SelectionSet, obj *models.Notification) graphql.Marshaler { + fields := graphql.CollectFields(ec.RequestContext, sel, notificationImplementors) + + out := graphql.NewFieldSet(fields) + var invalids uint32 + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("Notification") + case "key": + out.Values[i] = ec._Notification_key(ctx, field, obj) + if out.Values[i] == graphql.Null { + invalids++ + } + case "type": + out.Values[i] = ec._Notification_type(ctx, field, obj) + if out.Values[i] == graphql.Null { + invalids++ + } + case "header": + out.Values[i] = ec._Notification_header(ctx, field, obj) + if out.Values[i] == graphql.Null { + invalids++ + } + case "content": + out.Values[i] = ec._Notification_content(ctx, field, obj) + if out.Values[i] == graphql.Null { + invalids++ + } + case "progress": + out.Values[i] = ec._Notification_progress(ctx, field, obj) + case "positive": + out.Values[i] = ec._Notification_positive(ctx, field, obj) + if out.Values[i] == graphql.Null { + invalids++ + } + case "negative": + out.Values[i] = ec._Notification_negative(ctx, field, obj) + if out.Values[i] == graphql.Null { + invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch() + if invalids > 0 { + return graphql.Null + } + return out +} + var photoImplementors = []string{"Photo"} func (ec *executionContext) _Photo(ctx context.Context, sel ast.SelectionSet, obj *models.Photo) graphql.Marshaler { @@ -6295,6 +6774,26 @@ func (ec *executionContext) _SiteInfo(ctx context.Context, sel ast.SelectionSet, return out } +var subscriptionImplementors = []string{"Subscription"} + +func (ec *executionContext) _Subscription(ctx context.Context, sel ast.SelectionSet) func() graphql.Marshaler { + fields := graphql.CollectFields(ec.RequestContext, sel, subscriptionImplementors) + ctx = graphql.WithResolverContext(ctx, &graphql.ResolverContext{ + Object: "Subscription", + }) + if len(fields) != 1 { + ec.Errorf(ctx, "must subscribe to exactly one stream") + return nil + } + + switch fields[0].Name { + case "notification": + return ec._Subscription_notification(ctx, fields[0]) + default: + panic("unknown field " + strconv.Quote(fields[0].Name)) + } +} + var userImplementors = []string{"User"} func (ec *executionContext) _User(ctx context.Context, sel ast.SelectionSet, obj *models.User) graphql.Marshaler { @@ -6675,6 +7174,29 @@ func (ec *executionContext) marshalNInt2int(ctx context.Context, sel ast.Selecti return res } +func (ec *executionContext) marshalNNotification2githubᚗcomᚋviktorstrateᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐNotification(ctx context.Context, sel ast.SelectionSet, v models.Notification) graphql.Marshaler { + return ec._Notification(ctx, sel, &v) +} + +func (ec *executionContext) marshalNNotification2ᚖgithubᚗcomᚋviktorstrateᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐNotification(ctx context.Context, sel ast.SelectionSet, v *models.Notification) graphql.Marshaler { + if v == nil { + if !ec.HasError(graphql.GetResolverContext(ctx)) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + return ec._Notification(ctx, sel, v) +} + +func (ec *executionContext) unmarshalNNotificationType2githubᚗcomᚋviktorstrateᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐNotificationType(ctx context.Context, v interface{}) (models.NotificationType, error) { + var res models.NotificationType + return res, res.UnmarshalGQL(v) +} + +func (ec *executionContext) marshalNNotificationType2githubᚗcomᚋviktorstrateᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐNotificationType(ctx context.Context, sel ast.SelectionSet, v models.NotificationType) graphql.Marshaler { + return v +} + func (ec *executionContext) marshalNPhoto2githubᚗcomᚋviktorstrateᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐPhoto(ctx context.Context, sel ast.SelectionSet, v models.Photo) graphql.Marshaler { return ec._Photo(ctx, sel, &v) } diff --git a/api/graphql/models/generated.go b/api/graphql/models/generated.go index dc09490e..0b68552a 100644 --- a/api/graphql/models/generated.go +++ b/api/graphql/models/generated.go @@ -22,6 +22,16 @@ type Filter struct { Offset *int `json:"offset"` } +type Notification struct { + Key string `json:"key"` + Type NotificationType `json:"type"` + Header string `json:"header"` + Content string `json:"content"` + Progress *float64 `json:"progress"` + Positive bool `json:"positive"` + Negative bool `json:"negative"` +} + type PhotoDownload struct { Title string `json:"title"` URL string `json:"url"` @@ -63,6 +73,47 @@ type SiteInfo struct { InitialSetup bool `json:"initialSetup"` } +type NotificationType string + +const ( + NotificationTypeMessage NotificationType = "Message" + NotificationTypeProgress NotificationType = "Progress" +) + +var AllNotificationType = []NotificationType{ + NotificationTypeMessage, + NotificationTypeProgress, +} + +func (e NotificationType) IsValid() bool { + switch e { + case NotificationTypeMessage, NotificationTypeProgress: + return true + } + return false +} + +func (e NotificationType) String() string { + return string(e) +} + +func (e *NotificationType) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = NotificationType(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid NotificationType", str) + } + return nil +} + +func (e NotificationType) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + type OrderDirection string const ( diff --git a/api/graphql/notification/Notification.go b/api/graphql/notification/Notification.go new file mode 100644 index 00000000..6abd309a --- /dev/null +++ b/api/graphql/notification/Notification.go @@ -0,0 +1,64 @@ +package notification + +import ( + "errors" + "github.com/viktorstrate/photoview/api/graphql/models" + "log" + "sync" +) + +type NotificationChannel = <-chan *models.Notification + +type NotificationListener struct { + listenerID int + user models.User + channel NotificationChannel +} + +func NewListener(user models.User, channel NotificationChannel) *NotificationListener { + nextNotificationId++ + return &NotificationListener{ + listenerID: nextNotificationId, + user: user, + channel: channel, + } +} + +var notificationListeners []*NotificationListener = make([]*NotificationListener, 0) +var nextNotificationId = 0 +var notificationLock = &sync.Mutex{} + +func RegisterListener(user *models.User, channel NotificationChannel) int { + log.Println("Registering notification listener") + + notificationLock.Lock() + defer notificationLock.Unlock() + + notificationListeners = append(notificationListeners, NewListener(*user, channel)) + return nextNotificationId +} + +func DeregisterListener(listenerID int) error { + + notificationLock.Lock() + defer notificationLock.Unlock() + + for i, listener := range notificationListeners { + if listener.listenerID == listenerID { + + if len(notificationListeners) > 1 { + lastIndex := len(notificationListeners) - 1 + lastListener := notificationListeners[lastIndex] + notificationListeners[i] = lastListener + notificationListeners[lastIndex] = nil + notificationListeners = notificationListeners[:lastIndex] + } else { + notificationListeners = make([]*NotificationListener, 0) + } + + return nil + } + } + + return errors.New("ListenerID not found, while trying to deregister it") +} diff --git a/api/graphql/resolvers/notification.go b/api/graphql/resolvers/notification.go new file mode 100644 index 00000000..9c1ec71f --- /dev/null +++ b/api/graphql/resolvers/notification.go @@ -0,0 +1,27 @@ +package resolvers + +import ( + "context" + + "github.com/viktorstrate/photoview/api/graphql/auth" + "github.com/viktorstrate/photoview/api/graphql/notification" +) + +func (r *subscriptionResolver) Notification(ctx context.Context) (notification.NotificationChannel, error) { + + user := auth.UserFromContext(ctx) + if user == nil { + return nil, auth.ErrUnauthorized + } + + notificationChannel := make(notification.NotificationChannel, 1) + + listenerID := notification.RegisterListener(user, notificationChannel) + + go func() { + <-ctx.Done() + notification.DeregisterListener(listenerID) + }() + + return notificationChannel, nil +} diff --git a/api/graphql/resolvers/root.go b/api/graphql/resolvers/root.go index 19e58db2..46b8f06c 100644 --- a/api/graphql/resolvers/root.go +++ b/api/graphql/resolvers/root.go @@ -22,10 +22,20 @@ func (r *Resolver) Query() api.QueryResolver { return &queryResolver{r} } +func (r *Resolver) Subscription() api.SubscriptionResolver { + return &subscriptionResolver{ + Resolver: r, + } +} + type mutationResolver struct{ *Resolver } type queryResolver struct{ *Resolver } +type subscriptionResolver struct { + Resolver *Resolver +} + func (r *queryResolver) SiteInfo(ctx context.Context) (*models.SiteInfo, error) { return models.GetSiteInfo(r.Database) } diff --git a/api/graphql/schema.graphql b/api/graphql/schema.graphql index 54c15e93..92d9804c 100644 --- a/api/graphql/schema.graphql +++ b/api/graphql/schema.graphql @@ -85,6 +85,25 @@ type Mutation { deleteUser(id: Int!): User @isAdmin } +type Subscription { + notification: Notification! +} + +enum NotificationType { + Message + Progress +} + +type Notification { + key: String! + type: NotificationType! + header: String! + content: String! + progress: Float + positive: Boolean! + negative: Boolean! +} + type AuthorizeResult { success: Boolean! status: String! diff --git a/api/routes/photos.go b/api/routes/photos.go index e629b7ed..3455017b 100644 --- a/api/routes/photos.go +++ b/api/routes/photos.go @@ -8,15 +8,16 @@ import ( "net/http" "os" - "github.com/go-chi/chi" + "github.com/gorilla/mux" + "github.com/viktorstrate/photoview/api/graphql/auth" "github.com/viktorstrate/photoview/api/graphql/models" ) -func PhotoRoutes(db *sql.DB) chi.Router { - router := chi.NewRouter() - router.Get("/{name}", func(w http.ResponseWriter, r *http.Request) { - image_name := chi.URLParam(r, "name") +func RegisterPhotoRoutes(db *sql.DB, router *mux.Router) { + + router.HandleFunc("/{name}", func(w http.ResponseWriter, r *http.Request) { + image_name := mux.Vars(r)["name"] row := db.QueryRow("SELECT photo_url.purpose, photo.path, photo.photo_id, photo.album_id, photo_url.content_type FROM photo_url, photo WHERE photo_url.photo_name = ? AND photo_url.photo_id = photo.photo_id", image_name) @@ -129,6 +130,4 @@ func PhotoRoutes(db *sql.DB) chi.Router { io.Copy(w, file) }) - - return router } diff --git a/api/server/server.go b/api/server.go similarity index 53% rename from api/server/server.go rename to api/server.go index 2b0e8ece..d861163a 100644 --- a/api/server/server.go +++ b/api/server.go @@ -7,14 +7,16 @@ import ( "os" "path" - "github.com/go-chi/chi" - "github.com/go-chi/chi/middleware" - "github.com/go-chi/cors" + "github.com/gorilla/mux" + + // "github.com/go-chi/chi/middleware" + // "github.com/go-chi/cors" "github.com/joho/godotenv" "github.com/viktorstrate/photoview/api/database" "github.com/viktorstrate/photoview/api/graphql/auth" "github.com/viktorstrate/photoview/api/routes" + "github.com/viktorstrate/photoview/api/server" "github.com/99designs/gqlgen/handler" photoview_graphql "github.com/viktorstrate/photoview/api/graphql" @@ -44,18 +46,20 @@ func main() { log.Fatalf("Could not migrate database: %s\n", err) } - router := chi.NewRouter() - router.Use(auth.Middleware(db)) + rootRouter := mux.NewRouter() + rootRouter.Use(auth.Middleware(db)) - router.Use(middleware.Logger) + // router.Use(middleware.Logger) - router.Use(cors.New(cors.Options{ - AllowedOrigins: []string{"http://localhost:4001", "http://localhost:1234", "*"}, - AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, - AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"}, - AllowCredentials: true, - Debug: false, - }).Handler) + rootRouter.Use(server.CORSMiddleware(devMode)) + + // router.Use(cors.New(cors.Options{ + // AllowedOrigins: []string{"http://localhost:4001", "http://localhost:1234", "*"}, + // AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, + // AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"}, + // AllowCredentials: true, + // Debug: false, + // }).Handler) graphqlResolver := resolvers.Resolver{Database: db} graphqlDirective := photoview_graphql.DirectiveRoot{} @@ -72,19 +76,20 @@ func main() { endpointURL, _ = url.Parse("/") } - router.Route(endpointURL.Path, func(router chi.Router) { - if devMode { - router.Handle("/", handler.Playground("GraphQL playground", path.Join(endpointURL.Path, "/graphql"))) - } else { - router.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) { - w.Write([]byte("photoview api endpoint")) - }) - } + endpointRouter := rootRouter.PathPrefix(endpointURL.Path).Subrouter() - router.Handle("/graphql", handler.GraphQL(photoview_graphql.NewExecutableSchema(graphqlConfig), handler.IntrospectionEnabled(devMode))) + if devMode { + endpointRouter.Handle("/", handler.Playground("GraphQL playground", path.Join(endpointURL.Path, "/graphql"))) + } else { + endpointRouter.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) { + w.Write([]byte("photoview api endpoint")) + }) + } - router.Mount("/photo", routes.PhotoRoutes(db)) - }) + endpointRouter.Handle("/graphql", handler.GraphQL(photoview_graphql.NewExecutableSchema(graphqlConfig), handler.IntrospectionEnabled(devMode))) + + photoRouter := endpointRouter.PathPrefix("/photo").Subrouter() + routes.RegisterPhotoRoutes(db, photoRouter) if devMode { log.Printf("🚀 Graphql playground ready at %s", endpointURL.String()) @@ -92,5 +97,5 @@ func main() { log.Printf("Photoview API endpoint available at %s", endpointURL.String()) } - log.Fatal(http.ListenAndServe(":"+port, router)) + log.Fatal(http.ListenAndServe(":"+port, rootRouter)) } diff --git a/api/server/cors_middleware.go b/api/server/cors_middleware.go new file mode 100644 index 00000000..f8f6edcf --- /dev/null +++ b/api/server/cors_middleware.go @@ -0,0 +1,48 @@ +package server + +import ( + "log" + "net/http" + "net/url" + "os" + "path" + "strings" + + "github.com/gorilla/mux" +) + +func CORSMiddleware(devMode bool) mux.MiddlewareFunc { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + methods := []string{http.MethodGet, http.MethodPost, http.MethodOptions} + headers := []string{"authorization", "content-type"} + + w.Header().Set("Access-Control-Allow-Methods", strings.Join(methods, ",")) + w.Header().Set("Access-Control-Allow-Headers", strings.Join(headers, ",")) + + endpoint, err := url.Parse(os.Getenv("API_ENDPOINT")) + if err != nil { + log.Fatalln("Could not parse API_ENDPOINT environment variable as url") + } + endpoint.Path = path.Join(endpoint.Path, "graphql") + + if devMode { + // Development environment + w.Header().Set("Access-Control-Allow-Origin", req.Header.Get("origin")) + w.Header().Set("Vary", "Origin") + } else { + // Production environment + publicEndpoint, err := url.Parse(os.Getenv("PUBLIC_ENDPOINT")) + if err != nil { + log.Printf("Error parsing environment variable PUBLIC_ENDPOINT as url: %s", err) + } else { + w.Header().Set("Access-Control-Allow-Origin", publicEndpoint.Scheme+"://"+publicEndpoint.Host) + } + } + + if req.Method != http.MethodOptions { + next.ServeHTTP(w, req) + } + }) + } +}