From 732ccd05ab90546f77deb750ecb469f6240aa613 Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Sun, 20 Dec 2020 17:10:00 +0100 Subject: [PATCH 1/8] restructure user <-> albums relations in database --- api/database/database.go | 1 + api/go.sum | 5 + api/graphql/generated.go | 260 ++++++++++++--------------------- api/graphql/models/media.go | 22 +-- api/graphql/models/user.go | 69 ++------- api/graphql/resolvers/media.go | 42 +++++- api/graphql/resolvers/user.go | 46 +----- api/graphql/schema.graphql | 14 +- api/scanner/scanner_user.go | 42 +++--- 9 files changed, 193 insertions(+), 308 deletions(-) diff --git a/api/database/database.go b/api/database/database.go index 6855a0b7..1ea5e1e0 100644 --- a/api/database/database.go +++ b/api/database/database.go @@ -60,6 +60,7 @@ func MigrateDatabase(db *gorm.DB) error { &models.MediaEXIF{}, &models.VideoMetadata{}, &models.ShareToken{}, + &models.UserMediaData{}, ) return nil diff --git a/api/go.sum b/api/go.sum index 4217c8fb..0d6b6c94 100644 --- a/api/go.sum +++ b/api/go.sum @@ -9,6 +9,7 @@ github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNg github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= 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/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 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= @@ -64,16 +65,19 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE 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/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU= +github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/shurcooL/httpfs v0.0.0-20171119174359-809beceb2371/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg= +github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/shurcooL/vfsgen v0.0.0-20180121065927-ffb13db8def0/go.mod h1:TrYk7fJVaAttu97ZZKrO9UbRa8izdowaMIZcxYMbVaw= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/urfave/cli/v2 v2.1.1 h1:Qt8FeAtxE/vfdrLmR3rxR6JRE0RoVmbXu8+6kZtYU4k= github.com/urfave/cli/v2 v2.1.1/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ= github.com/vektah/dataloaden v0.2.1-0.20190515034641-a19b9a6e7c9e/go.mod h1:/HUdMve7rvxZma+2ZELQeNh88+003LL7Pf/CZ089j8U= github.com/vektah/gqlparser/v2 v2.1.0 h1:uiKJ+T5HMGGQM2kRKQ8Pxw8+Zq9qhhZhz/lieYvCMns= @@ -103,6 +107,7 @@ golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXR golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/tools v0.0.0-20190125232054-d66bd3c5d5a6/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190515012406-7d7faa4812bd/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20200114235610-7ae403b6b589 h1:rjUrONFu4kLchcZTfp3/96bR8bW8dIa8uz3cR5n0cgM= golang.org/x/tools v0.0.0-20200114235610-7ae403b6b589/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/api/graphql/generated.go b/api/graphql/generated.go index 071d41cc..b03b397f 100644 --- a/api/graphql/generated.go +++ b/api/graphql/generated.go @@ -115,20 +115,19 @@ type ComplexityRoot struct { Mutation struct { AuthorizeUser func(childComplexity int, username string, password string) int - CreateUser func(childComplexity int, username string, rootPath string, password *string, admin bool) int + CreateUser func(childComplexity int, username string, password *string, admin bool) int DeleteShareToken func(childComplexity int, token string) int DeleteUser func(childComplexity int, id int) int FavoriteMedia func(childComplexity int, mediaID int, favorite bool) int InitialSetupWizard func(childComplexity int, username string, password string, rootPath string) int ProtectShareToken func(childComplexity int, token string, password *string) int - RegisterUser func(childComplexity int, username string, password string, rootPath string) int ScanAll func(childComplexity int) int ScanUser func(childComplexity int, userID int) int SetPeriodicScanInterval func(childComplexity int, interval int) int SetScannerConcurrentWorkers func(childComplexity int, workers int) int ShareAlbum func(childComplexity int, albumID int, expire *time.Time, password *string) int ShareMedia func(childComplexity int, mediaID int, expire *time.Time, password *string) int - UpdateUser func(childComplexity int, id int, username *string, rootPath *string, password *string, admin *bool) int + UpdateUser func(childComplexity int, id int, username *string, password *string, admin *bool) int } Notification struct { @@ -193,8 +192,8 @@ type ComplexityRoot struct { User struct { Admin func(childComplexity int) int + Albums func(childComplexity int) int ID func(childComplexity int) int - RootPath func(childComplexity int) int Username func(childComplexity int) int } @@ -225,12 +224,13 @@ type MediaResolver interface { HighRes(ctx context.Context, obj *models.Media) (*models.MediaURL, error) VideoWeb(ctx context.Context, obj *models.Media) (*models.MediaURL, error) + Favorite(ctx context.Context, obj *models.Media) (bool, error) + Shares(ctx context.Context, obj *models.Media) ([]*models.ShareToken, error) Downloads(ctx context.Context, obj *models.Media) ([]*models.MediaDownload, error) } type MutationResolver interface { AuthorizeUser(ctx context.Context, username string, password string) (*models.AuthorizeResult, error) - RegisterUser(ctx context.Context, username string, password string, rootPath string) (*models.AuthorizeResult, error) InitialSetupWizard(ctx context.Context, username string, password string, rootPath string) (*models.AuthorizeResult, error) ScanAll(ctx context.Context) (*models.ScannerResult, error) ScanUser(ctx context.Context, userID int) (*models.ScannerResult, error) @@ -239,8 +239,8 @@ type MutationResolver interface { DeleteShareToken(ctx context.Context, token string) (*models.ShareToken, error) ProtectShareToken(ctx context.Context, token string, password *string) (*models.ShareToken, error) FavoriteMedia(ctx context.Context, mediaID int, favorite bool) (*models.Media, error) - UpdateUser(ctx context.Context, id int, username *string, rootPath *string, password *string, admin *bool) (*models.User, error) - CreateUser(ctx context.Context, username string, rootPath string, password *string, admin bool) (*models.User, error) + UpdateUser(ctx context.Context, id int, username *string, password *string, admin *bool) (*models.User, error) + CreateUser(ctx context.Context, username string, password *string, admin bool) (*models.User, error) DeleteUser(ctx context.Context, id int) (*models.User, error) SetPeriodicScanInterval(ctx context.Context, interval int) (int, error) SetScannerConcurrentWorkers(ctx context.Context, workers int) (int, error) @@ -622,7 +622,7 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return 0, false } - return e.complexity.Mutation.CreateUser(childComplexity, args["username"].(string), args["rootPath"].(string), args["password"].(*string), args["admin"].(bool)), true + return e.complexity.Mutation.CreateUser(childComplexity, args["username"].(string), args["password"].(*string), args["admin"].(bool)), true case "Mutation.deleteShareToken": if e.complexity.Mutation.DeleteShareToken == nil { @@ -684,18 +684,6 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Mutation.ProtectShareToken(childComplexity, args["token"].(string), args["password"].(*string)), true - case "Mutation.registerUser": - if e.complexity.Mutation.RegisterUser == nil { - break - } - - args, err := ec.field_Mutation_registerUser_args(context.TODO(), rawArgs) - if err != nil { - return 0, false - } - - return e.complexity.Mutation.RegisterUser(childComplexity, args["username"].(string), args["password"].(string), args["rootPath"].(string)), true - case "Mutation.scanAll": if e.complexity.Mutation.ScanAll == nil { break @@ -773,7 +761,7 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return 0, false } - return e.complexity.Mutation.UpdateUser(childComplexity, args["id"].(int), args["username"].(*string), args["rootPath"].(*string), args["password"].(*string), args["admin"].(*bool)), true + return e.complexity.Mutation.UpdateUser(childComplexity, args["id"].(int), args["username"].(*string), args["password"].(*string), args["admin"].(*bool)), true case "Notification.content": if e.complexity.Notification.Content == nil { @@ -1100,6 +1088,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.User.Admin(childComplexity), true + case "User.albums": + if e.complexity.User.Albums == nil { + break + } + + return e.complexity.User.Albums(childComplexity), true + case "User.id": if e.complexity.User.ID == nil { break @@ -1107,13 +1102,6 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.User.ID(childComplexity), true - case "User.rootPath": - if e.complexity.User.RootPath == nil { - break - } - - return e.complexity.User.RootPath(childComplexity), true - case "User.username": if e.complexity.User.Username == nil { break @@ -1332,13 +1320,6 @@ type Query { type Mutation { authorizeUser(username: String!, password: String!): AuthorizeResult! - "Registers a new user, must be admin to call" - registerUser( - username: String! - password: String! - rootPath: String! - ): AuthorizeResult! - "Registers the initial user, can only be called if initialSetup from SiteInfo is true" initialSetupWizard( username: String! @@ -1366,13 +1347,11 @@ type Mutation { updateUser( id: ID! username: String - rootPath: String password: String admin: Boolean ): User @isAdmin createUser( username: String! - rootPath: String! password: String admin: Boolean! ): User @isAdmin @@ -1454,8 +1433,9 @@ type User { id: ID! username: String! #albums: [Album] - "Local filepath for the user's photos" - rootPath: String! @isAdmin + # rootPath: String! @isAdmin + "Top level albums owned by this user" + albums: [Album!]! @isAdmin admin: Boolean! #shareTokens: [ShareToken] } @@ -1653,33 +1633,24 @@ func (ec *executionContext) field_Mutation_createUser_args(ctx context.Context, } } args["username"] = arg0 - var arg1 string - if tmp, ok := rawArgs["rootPath"]; ok { - ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("rootPath")) - arg1, err = ec.unmarshalNString2string(ctx, tmp) - if err != nil { - return nil, err - } - } - args["rootPath"] = arg1 - var arg2 *string + var arg1 *string if tmp, ok := rawArgs["password"]; ok { ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("password")) - arg2, err = ec.unmarshalOString2ᚖstring(ctx, tmp) + arg1, err = ec.unmarshalOString2ᚖstring(ctx, tmp) if err != nil { return nil, err } } - args["password"] = arg2 - var arg3 bool + args["password"] = arg1 + var arg2 bool if tmp, ok := rawArgs["admin"]; ok { ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("admin")) - arg3, err = ec.unmarshalNBoolean2bool(ctx, tmp) + arg2, err = ec.unmarshalNBoolean2bool(ctx, tmp) if err != nil { return nil, err } } - args["admin"] = arg3 + args["admin"] = arg2 return args, nil } @@ -1794,39 +1765,6 @@ func (ec *executionContext) field_Mutation_protectShareToken_args(ctx context.Co return args, nil } -func (ec *executionContext) field_Mutation_registerUser_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { - var err error - args := map[string]interface{}{} - var arg0 string - if tmp, ok := rawArgs["username"]; ok { - ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("username")) - arg0, err = ec.unmarshalNString2string(ctx, tmp) - if err != nil { - return nil, err - } - } - args["username"] = arg0 - var arg1 string - if tmp, ok := rawArgs["password"]; ok { - ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("password")) - arg1, err = ec.unmarshalNString2string(ctx, tmp) - if err != nil { - return nil, err - } - } - args["password"] = arg1 - var arg2 string - if tmp, ok := rawArgs["rootPath"]; ok { - ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("rootPath")) - arg2, err = ec.unmarshalNString2string(ctx, tmp) - if err != nil { - return nil, err - } - } - args["rootPath"] = arg2 - return args, nil -} - func (ec *executionContext) field_Mutation_scanUser_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} @@ -1960,32 +1898,23 @@ func (ec *executionContext) field_Mutation_updateUser_args(ctx context.Context, } args["username"] = arg1 var arg2 *string - if tmp, ok := rawArgs["rootPath"]; ok { - ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("rootPath")) + if tmp, ok := rawArgs["password"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("password")) arg2, err = ec.unmarshalOString2ᚖstring(ctx, tmp) if err != nil { return nil, err } } - args["rootPath"] = arg2 - var arg3 *string - if tmp, ok := rawArgs["password"]; ok { - ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("password")) - arg3, err = ec.unmarshalOString2ᚖstring(ctx, tmp) - if err != nil { - return nil, err - } - } - args["password"] = arg3 - var arg4 *bool + args["password"] = arg2 + var arg3 *bool if tmp, ok := rawArgs["admin"]; ok { ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("admin")) - arg4, err = ec.unmarshalOBoolean2ᚖbool(ctx, tmp) + arg3, err = ec.unmarshalOBoolean2ᚖbool(ctx, tmp) if err != nil { return nil, err } } - args["admin"] = arg4 + args["admin"] = arg3 return args, nil } @@ -3011,14 +2940,14 @@ func (ec *executionContext) _Media_favorite(ctx context.Context, field graphql.C Object: "Media", Field: field, Args: nil, - IsMethod: false, - IsResolver: false, + IsMethod: true, + IsResolver: true, } ctx = graphql.WithFieldContext(ctx, fc) resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { ctx = rctx // use context from middleware stack in children - return obj.Favorite, nil + return ec.resolvers.Media().Favorite(rctx, obj) }) if err != nil { ec.Error(ctx, err) @@ -3782,48 +3711,6 @@ func (ec *executionContext) _Mutation_authorizeUser(ctx context.Context, field g return ec.marshalNAuthorizeResult2ᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐAuthorizeResult(ctx, field.Selections, res) } -func (ec *executionContext) _Mutation_registerUser(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { - defer func() { - if r := recover(); r != nil { - ec.Error(ctx, ec.Recover(ctx, r)) - ret = graphql.Null - } - }() - fc := &graphql.FieldContext{ - Object: "Mutation", - Field: field, - Args: nil, - IsMethod: true, - IsResolver: true, - } - - ctx = graphql.WithFieldContext(ctx, fc) - rawArgs := field.ArgumentMap(ec.Variables) - args, err := ec.field_Mutation_registerUser_args(ctx, rawArgs) - if err != nil { - ec.Error(ctx, err) - return graphql.Null - } - fc.Args = args - resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { - ctx = rctx // use context from middleware stack in children - return ec.resolvers.Mutation().RegisterUser(rctx, args["username"].(string), args["password"].(string), args["rootPath"].(string)) - }) - 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.(*models.AuthorizeResult) - fc.Result = res - return ec.marshalNAuthorizeResult2ᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐAuthorizeResult(ctx, field.Selections, res) -} - func (ec *executionContext) _Mutation_initialSetupWizard(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { @@ -4181,7 +4068,7 @@ func (ec *executionContext) _Mutation_updateUser(ctx context.Context, field grap resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { directive0 := func(rctx context.Context) (interface{}, error) { ctx = rctx // use context from middleware stack in children - return ec.resolvers.Mutation().UpdateUser(rctx, args["id"].(int), args["username"].(*string), args["rootPath"].(*string), args["password"].(*string), args["admin"].(*bool)) + return ec.resolvers.Mutation().UpdateUser(rctx, args["id"].(int), args["username"].(*string), args["password"].(*string), args["admin"].(*bool)) } directive1 := func(ctx context.Context) (interface{}, error) { if ec.directives.IsAdmin == nil { @@ -4240,7 +4127,7 @@ func (ec *executionContext) _Mutation_createUser(ctx context.Context, field grap resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { directive0 := func(rctx context.Context) (interface{}, error) { ctx = rctx // use context from middleware stack in children - return ec.resolvers.Mutation().CreateUser(rctx, args["username"].(string), args["rootPath"].(string), args["password"].(*string), args["admin"].(bool)) + return ec.resolvers.Mutation().CreateUser(rctx, args["username"].(string), args["password"].(*string), args["admin"].(bool)) } directive1 := func(ctx context.Context) (interface{}, error) { if ec.directives.IsAdmin == nil { @@ -6031,7 +5918,7 @@ func (ec *executionContext) _User_username(ctx context.Context, field graphql.Co return ec.marshalNString2string(ctx, field.Selections, res) } -func (ec *executionContext) _User_rootPath(ctx context.Context, field graphql.CollectedField, obj *models.User) (ret graphql.Marshaler) { +func (ec *executionContext) _User_albums(ctx context.Context, field graphql.CollectedField, obj *models.User) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { ec.Error(ctx, ec.Recover(ctx, r)) @@ -6050,7 +5937,7 @@ func (ec *executionContext) _User_rootPath(ctx context.Context, field graphql.Co resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { directive0 := func(rctx context.Context) (interface{}, error) { ctx = rctx // use context from middleware stack in children - return obj.RootPath, nil + return obj.Albums, nil } directive1 := func(ctx context.Context) (interface{}, error) { if ec.directives.IsAdmin == nil { @@ -6066,10 +5953,10 @@ func (ec *executionContext) _User_rootPath(ctx context.Context, field graphql.Co if tmp == nil { return nil, nil } - if data, ok := tmp.(string); ok { + if data, ok := tmp.([]models.Album); ok { return data, nil } - return nil, fmt.Errorf(`unexpected type %T from directive, should be string`, tmp) + return nil, fmt.Errorf(`unexpected type %T from directive, should be []github.com/photoview/photoview/api/graphql/models.Album`, tmp) }) if err != nil { ec.Error(ctx, err) @@ -6081,9 +5968,9 @@ func (ec *executionContext) _User_rootPath(ctx context.Context, field graphql.Co } return graphql.Null } - res := resTmp.(string) + res := resTmp.([]models.Album) fc.Result = res - return ec.marshalNString2string(ctx, field.Selections, res) + return ec.marshalNAlbum2ᚕgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐAlbumᚄ(ctx, field.Selections, res) } func (ec *executionContext) _User_admin(ctx context.Context, field graphql.CollectedField, obj *models.User) (ret graphql.Marshaler) { @@ -7809,10 +7696,19 @@ func (ec *executionContext) _Media(ctx context.Context, sel ast.SelectionSet, ob case "videoMetadata": out.Values[i] = ec._Media_videoMetadata(ctx, field, obj) case "favorite": - out.Values[i] = ec._Media_favorite(ctx, field, obj) - if out.Values[i] == graphql.Null { - atomic.AddUint32(&invalids, 1) - } + field := field + out.Concurrently(i, func() (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Media_favorite(ctx, field, obj) + if res == graphql.Null { + atomic.AddUint32(&invalids, 1) + } + return res + }) case "type": out.Values[i] = ec._Media_type(ctx, field, obj) if out.Values[i] == graphql.Null { @@ -8003,11 +7899,6 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) if out.Values[i] == graphql.Null { invalids++ } - case "registerUser": - out.Values[i] = ec._Mutation_registerUser(ctx, field) - if out.Values[i] == graphql.Null { - invalids++ - } case "initialSetupWizard": out.Values[i] = ec._Mutation_initialSetupWizard(ctx, field) case "scanAll": @@ -8530,8 +8421,8 @@ func (ec *executionContext) _User(ctx context.Context, sel ast.SelectionSet, obj if out.Values[i] == graphql.Null { invalids++ } - case "rootPath": - out.Values[i] = ec._User_rootPath(ctx, field, obj) + case "albums": + out.Values[i] = ec._User_albums(ctx, field, obj) if out.Values[i] == graphql.Null { invalids++ } @@ -8857,6 +8748,43 @@ func (ec *executionContext) marshalNAlbum2githubᚗcomᚋphotoviewᚋphotoview return ec._Album(ctx, sel, &v) } +func (ec *executionContext) marshalNAlbum2ᚕgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐAlbumᚄ(ctx context.Context, sel ast.SelectionSet, v []models.Album) graphql.Marshaler { + ret := make(graphql.Array, len(v)) + var wg sync.WaitGroup + isLen1 := len(v) == 1 + if !isLen1 { + wg.Add(len(v)) + } + for i := range v { + i := i + fc := &graphql.FieldContext{ + Index: &i, + Result: &v[i], + } + ctx := graphql.WithFieldContext(ctx, fc) + f := func(i int) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = nil + } + }() + if !isLen1 { + defer wg.Done() + } + ret[i] = ec.marshalNAlbum2githubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐAlbum(ctx, sel, v[i]) + } + if isLen1 { + f(i) + } else { + go f(i) + } + + } + wg.Wait() + return ret +} + func (ec *executionContext) marshalNAlbum2ᚕᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐAlbumᚄ(ctx context.Context, sel ast.SelectionSet, v []*models.Album) graphql.Marshaler { ret := make(graphql.Array, len(v)) var wg sync.WaitGroup diff --git a/api/graphql/models/media.go b/api/graphql/models/media.go index 2fe9eb28..ecaf12e7 100644 --- a/api/graphql/models/media.go +++ b/api/graphql/models/media.go @@ -13,17 +13,17 @@ import ( type Media struct { Model - Title string `gorm:"not null"` - Path string `gorm:"not null"` - PathHash string `gorm:"not null"` - AlbumID int `gorm:"not null"` - Album Album - ExifID *int - Exif *MediaEXIF - MediaURL []MediaURL - DateShot time.Time `gorm:"not null"` - DateImported time.Time `gorm:"not null"` - Favorite bool `gorm:"not null, default:false"` + Title string `gorm:"not null"` + Path string `gorm:"not null"` + PathHash string `gorm:"not null"` + AlbumID int `gorm:"not null"` + Album Album + ExifID *int + Exif *MediaEXIF + MediaURL []MediaURL + DateShot time.Time `gorm:"not null"` + DateImported time.Time `gorm:"not null"` + // Favorite bool `gorm:"not null, default:false"` Type MediaType `gorm:"not null"` VideoMetadataID *int VideoMetadata *VideoMetadata diff --git a/api/graphql/models/user.go b/api/graphql/models/user.go index 7b2f1432..94b1a1c4 100644 --- a/api/graphql/models/user.go +++ b/api/graphql/models/user.go @@ -16,13 +16,17 @@ type User struct { Model Username string `gorm:"unique,size:128"` Password *string `gorm:"size:256` - RootPath string `gorm:"size:512` - Admin bool `gorm:"default:false"` + // RootPath string `gorm:"size:512` + Albums []Album `gorm:"many2many:user_albums"` + Admin bool `gorm:"default:false"` } -// func (u *User) ID() int { -// return u.UserID -// } +type UserMediaData struct { + ModelTimestamps + UserID int `gorm:"primaryKey;autoIncrement:false"` + MediaID int `gorm:"primaryKey;autoIncrement:false"` + Favorite bool `gorm:"not null;default:false"` +} type AccessToken struct { Model @@ -34,35 +38,7 @@ type AccessToken struct { var ErrorInvalidUserCredentials = errors.New("invalid credentials") -// func NewUserFromRow(row *sql.Row) (*User, error) { -// user := User{} - -// if err := row.Scan(&user.UserID, &user.Username, &user.Password, &user.RootPath, &user.Admin); err != nil { -// return nil, errors.Wrap(err, "failed to scan user from database") -// } - -// return &user, nil -// } - -// func NewUsersFromRows(rows *sql.Rows) ([]*User, error) { -// users := make([]*User, 0) - -// for rows.Next() { -// var user User -// if err := rows.Scan(&user.UserID, &user.Username, &user.Password, &user.RootPath, &user.Admin); err != nil { -// return nil, errors.Wrap(err, "failed to scan users from database") -// } -// users = append(users, &user) -// } - -// rows.Close() - -// return users, nil -// } - func AuthorizeUser(db *gorm.DB, username string, password string) (*User, error) { - // row := database.QueryRow("SELECT * FROM user WHERE username = ?", username) - var user User result := db.Where("username = ?", username).First(&user) @@ -100,15 +76,15 @@ func ValidRootPath(rootPath string) bool { return true } -func RegisterUser(db *gorm.DB, username string, password *string, rootPath string, admin bool) (*User, error) { - if !ValidRootPath(rootPath) { - return nil, ErrorInvalidRootPath - } +func RegisterUser(db *gorm.DB, username string, password *string, admin bool) (*User, error) { + // if !ValidRootPath(rootPath) { + // return nil, ErrorInvalidRootPath + // } user := User{ Username: username, - RootPath: rootPath, - Admin: admin, + // RootPath: rootPath, + Admin: admin, } if password != nil { @@ -163,27 +139,12 @@ func (user *User) GenerateAccessToken(db *gorm.DB) (*AccessToken, error) { func VerifyTokenAndGetUser(db *gorm.DB, token string) (*User, error) { - // row := database.QueryRow("SELECT (user_id) FROM access_token WHERE expire > ? AND value = ?", now, token) - var accessToken AccessToken result := db.Where("expire > ? AND value = ?", time.Now(), token).First(&accessToken) if result.Error != nil { return nil, result.Error } - // var userId string - - // if err := row.Scan(&userId); err != nil { - // log.Println(err.Error()) - // return nil, err - // } - - // row = db.QueryRow("SELECT * FROM user WHERE user_id = ?", userId) - // user, err := NewUserFromRow(row) - // if err != nil { - // return nil, err - // } - var user User result = db.First(&user, accessToken.UserID) if result.Error != nil { diff --git a/api/graphql/resolvers/media.go b/api/graphql/resolvers/media.go index d7d00024..a6b82313 100644 --- a/api/graphql/resolvers/media.go +++ b/api/graphql/resolvers/media.go @@ -9,6 +9,7 @@ import ( "github.com/photoview/photoview/api/scanner" "github.com/pkg/errors" "gorm.io/gorm" + "gorm.io/gorm/clause" ) func (r *queryResolver) MyMedia(ctx context.Context, filter *models.Filter) ([]*models.Media, error) { @@ -182,20 +183,45 @@ func (r *mediaResolver) VideoWeb(ctx context.Context, media *models.Media) (*mod return &url, nil } +func (r *mediaResolver) Favorite(ctx context.Context, media *models.Media) (bool, error) { + user := auth.UserFromContext(ctx) + if user == nil { + return false, auth.ErrUnauthorized + } + + userMediaData := models.UserMediaData{ + UserID: user.ID, + MediaID: media.ID, + Favorite: false, + } + + if err := r.Database.FirstOrInit(&userMediaData).Error; err != nil { + return false, errors.Wrapf(err, "get user media data from database (user: %d, media: %d)", user.ID, media.ID) + } + + return userMediaData.Favorite, nil +} + func (r *mutationResolver) FavoriteMedia(ctx context.Context, mediaID int, favorite bool) (*models.Media, error) { user := auth.UserFromContext(ctx) - - var media models.Media - - if err := r.Database.Joins("Album").Where("Album.owner_id = ?", user.ID).First(&media, mediaID).Error; err != nil { - return nil, err + if user == nil { + return nil, auth.ErrUnauthorized } - media.Favorite = favorite + userMediaData := models.UserMediaData{ + UserID: user.ID, + MediaID: mediaID, + Favorite: favorite, + } - if err := r.Database.Save(&media).Error; err != nil { - return nil, errors.Wrap(err, "failed to update media favorite on database") + if err := r.Database.Clauses(clause.OnConflict{UpdateAll: true}).Create(&userMediaData).Error; err != nil { + return nil, errors.Wrapf(err, "update user favorite media in database") + } + + var media models.Media + if err := r.Database.First(&media, mediaID).Error; err != nil { + return nil, errors.Wrap(err, "get media from database after favorite update") } return &media, nil diff --git a/api/graphql/resolvers/user.go b/api/graphql/resolvers/user.go index 50c0e986..f6d594d5 100644 --- a/api/graphql/resolvers/user.go +++ b/api/graphql/resolvers/user.go @@ -61,38 +61,6 @@ func (r *mutationResolver) AuthorizeUser(ctx context.Context, username string, p Token: &token.Value, }, nil } -func (r *mutationResolver) RegisterUser(ctx context.Context, username string, password string, rootPath string) (*models.AuthorizeResult, error) { - - var token *models.AccessToken - - transactionError := r.Database.Transaction(func(tx *gorm.DB) error { - user, err := models.RegisterUser(tx, username, &password, rootPath, false) - if err != nil { - return err - } - - token, err = user.GenerateAccessToken(tx) - if err != nil { - tx.Rollback() - return err - } - - return nil - }) - - if transactionError != nil { - return &models.AuthorizeResult{ - Success: false, - Status: transactionError.Error(), - }, transactionError - } - - return &models.AuthorizeResult{ - Success: true, - Status: "ok", - Token: &token.Value, - }, nil -} func (r *mutationResolver) InitialSetupWizard(ctx context.Context, username string, password string, rootPath string) (*models.AuthorizeResult, error) { siteInfo, err := models.GetSiteInfo(r.Database) @@ -111,7 +79,7 @@ func (r *mutationResolver) InitialSetupWizard(ctx context.Context, username stri return err } - user, err := models.RegisterUser(tx, username, &password, rootPath, true) + user, err := models.RegisterUser(tx, username, &password, true) if err != nil { return err } @@ -139,9 +107,9 @@ func (r *mutationResolver) InitialSetupWizard(ctx context.Context, username stri } // Admin queries -func (r *mutationResolver) UpdateUser(ctx context.Context, id int, username *string, rootPath *string, password *string, admin *bool) (*models.User, error) { +func (r *mutationResolver) UpdateUser(ctx context.Context, id int, username *string, password *string, admin *bool) (*models.User, error) { - if username == nil && rootPath == nil && password == nil && admin == nil { + if username == nil && password == nil && admin == nil { return nil, errors.New("no updates requested") } @@ -154,10 +122,6 @@ func (r *mutationResolver) UpdateUser(ctx context.Context, id int, username *str user.Username = *username } - if rootPath != nil { - user.RootPath = *rootPath - } - if password != nil { hashedPassBytes, err := bcrypt.GenerateFromPassword([]byte(*password), 12) if err != nil { @@ -179,13 +143,13 @@ func (r *mutationResolver) UpdateUser(ctx context.Context, id int, username *str return &user, nil } -func (r *mutationResolver) CreateUser(ctx context.Context, username string, rootPath string, password *string, admin bool) (*models.User, error) { +func (r *mutationResolver) CreateUser(ctx context.Context, username string, password *string, admin bool) (*models.User, error) { var user *models.User transactionError := r.Database.Transaction(func(tx *gorm.DB) error { var err error - user, err = models.RegisterUser(tx, username, password, rootPath, admin) + user, err = models.RegisterUser(tx, username, password, admin) if err != nil { return err } diff --git a/api/graphql/schema.graphql b/api/graphql/schema.graphql index 3c8e95cf..38bd197c 100644 --- a/api/graphql/schema.graphql +++ b/api/graphql/schema.graphql @@ -58,13 +58,6 @@ type Query { type Mutation { authorizeUser(username: String!, password: String!): AuthorizeResult! - "Registers a new user, must be admin to call" - registerUser( - username: String! - password: String! - rootPath: String! - ): AuthorizeResult! - "Registers the initial user, can only be called if initialSetup from SiteInfo is true" initialSetupWizard( username: String! @@ -92,13 +85,11 @@ type Mutation { updateUser( id: ID! username: String - rootPath: String password: String admin: Boolean ): User @isAdmin createUser( username: String! - rootPath: String! password: String admin: Boolean! ): User @isAdmin @@ -180,8 +171,9 @@ type User { id: ID! username: String! #albums: [Album] - "Local filepath for the user's photos" - rootPath: String! @isAdmin + # rootPath: String! @isAdmin + "Top level albums owned by this user" + albums: [Album!]! @isAdmin admin: Boolean! #shareTokens: [ShareToken] } diff --git a/api/scanner/scanner_user.go b/api/scanner/scanner_user.go index 1b1a322e..ca1b157d 100644 --- a/api/scanner/scanner_user.go +++ b/api/scanner/scanner_user.go @@ -17,29 +17,37 @@ import ( func findAlbumsForUser(db *gorm.DB, user *models.User, album_cache *AlbumScannerCache) ([]*models.Album, []error) { - // Check if user directory exists on the file system - if _, err := os.Stat(user.RootPath); err != nil { - if os.IsNotExist(err) { - return nil, []error{errors.Errorf("Photo directory for user '%s' does not exist '%s'\n", user.Username, user.RootPath)} - } else { - return nil, []error{errors.Errorf("Could not read photo directory for user '%s': %s\n", user.Username, user.RootPath)} - } + var userRootAlbums []*models.Album + if err := db.Model(&user).Association("Albums").Find(&userRootAlbums); err != nil { + return nil, []error{errors.Wrapf(err, "get albums of user (%s)", user.Username)} } + scanErrors := make([]error, 0) + type scanInfo struct { path string parentID *int } scanQueue := list.New() - scanQueue.PushBack(scanInfo{ - path: user.RootPath, - parentID: nil, - }) + + for _, album := range userRootAlbums { + // Check if user album directory exists on the file system + if _, err := os.Stat(album.Path); err != nil { + if os.IsNotExist(err) { + scanErrors = append(scanErrors, errors.Errorf("Album directory for user '%s' does not exist '%s'\n", user.Username, album.Path)) + } else { + scanErrors = append(scanErrors, errors.Errorf("Could not read album directory for user '%s': %s\n", user.Username, album.Path)) + } + } else { + scanQueue.PushBack(scanInfo{ + path: album.Path, + parentID: nil, + }) + } + } userAlbums := make([]*models.Album, 0) - albumErrors := make([]error, 0) - // newPhotos := make([]*models.Photo, 0) for scanQueue.Front() != nil { albumInfo := scanQueue.Front().Value.(scanInfo) @@ -51,7 +59,7 @@ func findAlbumsForUser(db *gorm.DB, user *models.User, album_cache *AlbumScanner // Read path dirContent, err := ioutil.ReadDir(albumPath) if err != nil { - albumErrors = append(albumErrors, errors.Wrapf(err, "read directory (%s)", albumPath)) + scanErrors = append(scanErrors, errors.Wrapf(err, "read directory (%s)", albumPath)) continue } @@ -81,7 +89,7 @@ func findAlbumsForUser(db *gorm.DB, user *models.User, album_cache *AlbumScanner }) if transErr != nil { - albumErrors = append(albumErrors, errors.Wrap(transErr, "begin database transaction")) + scanErrors = append(scanErrors, errors.Wrap(transErr, "begin database transaction")) continue } @@ -104,9 +112,9 @@ func findAlbumsForUser(db *gorm.DB, user *models.User, album_cache *AlbumScanner } deleteErrors := deleteOldUserAlbums(db, userAlbums, user) - albumErrors = append(albumErrors, deleteErrors...) + scanErrors = append(scanErrors, deleteErrors...) - return userAlbums, albumErrors + return userAlbums, scanErrors } func directoryContainsPhotos(rootPath string, cache *AlbumScannerCache) bool { From aeb05bca49ebd5c2765f9aa91c352232c7bcfd62 Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Tue, 22 Dec 2020 01:14:43 +0100 Subject: [PATCH 2/8] get initial scanner up and running --- api/graphql/generated.go | 29 ++++++--- api/graphql/models/album.go | 9 +-- api/graphql/models/user.go | 27 +++++++++ api/graphql/resolvers/album.go | 51 ++++++++++++++-- api/graphql/resolvers/media.go | 11 +++- api/graphql/resolvers/scanner.go | 1 - api/graphql/resolvers/user.go | 6 ++ api/routes/authenticate_media.go | 7 ++- api/scanner/cleanup_media.go | 29 +++++---- api/scanner/scanner_album.go | 19 ++++++ api/scanner/scanner_user.go | 79 ++++++++++++++++++------- ui/src/Pages/SettingsPage/UsersTable.js | 2 +- 12 files changed, 211 insertions(+), 59 deletions(-) diff --git a/api/graphql/generated.go b/api/graphql/generated.go index b03b397f..65985d37 100644 --- a/api/graphql/generated.go +++ b/api/graphql/generated.go @@ -215,6 +215,8 @@ type AlbumResolver interface { Media(ctx context.Context, obj *models.Album, filter *models.Filter, onlyFavorites *bool) ([]*models.Media, error) SubAlbums(ctx context.Context, obj *models.Album, filter *models.Filter) ([]*models.Album, error) + Owner(ctx context.Context, obj *models.Album) (*models.User, error) + Thumbnail(ctx context.Context, obj *models.Album) (*models.Media, error) Path(ctx context.Context, obj *models.Album) ([]*models.Album, error) Shares(ctx context.Context, obj *models.Album) ([]*models.ShareToken, error) @@ -2366,14 +2368,14 @@ func (ec *executionContext) _Album_owner(ctx context.Context, field graphql.Coll Object: "Album", Field: field, Args: nil, - IsMethod: false, - IsResolver: false, + IsMethod: true, + IsResolver: true, } ctx = graphql.WithFieldContext(ctx, fc) resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { ctx = rctx // use context from middleware stack in children - return obj.Owner, nil + return ec.resolvers.Album().Owner(rctx, obj) }) if err != nil { ec.Error(ctx, err) @@ -2385,9 +2387,9 @@ func (ec *executionContext) _Album_owner(ctx context.Context, field graphql.Coll } return graphql.Null } - res := resTmp.(models.User) + res := resTmp.(*models.User) fc.Result = res - return ec.marshalNUser2githubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐUser(ctx, field.Selections, res) + return ec.marshalNUser2ᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐUser(ctx, field.Selections, res) } func (ec *executionContext) _Album_filePath(ctx context.Context, field graphql.CollectedField, obj *models.Album) (ret graphql.Marshaler) { @@ -7534,10 +7536,19 @@ func (ec *executionContext) _Album(ctx context.Context, sel ast.SelectionSet, ob case "parentAlbum": out.Values[i] = ec._Album_parentAlbum(ctx, field, obj) case "owner": - out.Values[i] = ec._Album_owner(ctx, field, obj) - if out.Values[i] == graphql.Null { - atomic.AddUint32(&invalids, 1) - } + field := field + out.Concurrently(i, func() (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Album_owner(ctx, field, obj) + if res == graphql.Null { + atomic.AddUint32(&invalids, 1) + } + return res + }) case "filePath": out.Values[i] = ec._Album_filePath(ctx, field, obj) if out.Values[i] == graphql.Null { diff --git a/api/graphql/models/album.go b/api/graphql/models/album.go index 62b6b8a6..0e6149ac 100644 --- a/api/graphql/models/album.go +++ b/api/graphql/models/album.go @@ -12,10 +12,11 @@ type Album struct { Title string `gorm:"not null"` ParentAlbumID *int ParentAlbum *Album - OwnerID int `gorm:"not null"` - Owner User - Path string `gorm:"not null"` - PathHash string `gorm:"unique"` + // OwnerID int `gorm:"not null"` + // Owner User + Owners []User `gorm:"many2many:user_albums"` + Path string `gorm:"not null"` + PathHash string `gorm:"unique"` } func (a *Album) FilePath() string { diff --git a/api/graphql/models/user.go b/api/graphql/models/user.go index 94b1a1c4..2d4505d8 100644 --- a/api/graphql/models/user.go +++ b/api/graphql/models/user.go @@ -153,3 +153,30 @@ func VerifyTokenAndGetUser(db *gorm.DB, token string) (*User, error) { return &user, nil } + +// FillAlbums fill user.Albums with albums from database +func (user *User) FillAlbums(db *gorm.DB) error { + // Albums already present + if len(user.Albums) > 0 { + return nil + } + + if err := db.Model(&user).Association("Albums").Find(&user.Albums); err != nil { + return errors.Wrap(err, "fill user albums") + } + + return nil +} + +func (user *User) OwnsAlbum(db *gorm.DB, album *Album) (bool, error) { + + // user.QueryUserAlbums(db, db.Where("id = ?", album.ID)) + + // TODO: Implement this + return true, nil +} + +func (user *User) OwnsMedia(db *gorm.DB, media *Media) (bool, error) { + // TODO: implement this + return true, nil +} diff --git a/api/graphql/resolvers/album.go b/api/graphql/resolvers/album.go index b8313fb0..ea8b5a70 100644 --- a/api/graphql/resolvers/album.go +++ b/api/graphql/resolvers/album.go @@ -2,10 +2,12 @@ package resolvers import ( "context" + "errors" api "github.com/photoview/photoview/api/graphql" "github.com/photoview/photoview/api/graphql/auth" "github.com/photoview/photoview/api/graphql/models" + "gorm.io/gorm" ) func (r *queryResolver) MyAlbums(ctx context.Context, filter *models.Filter, onlyRoot *bool, showEmpty *bool, onlyWithFavorites *bool) ([]*models.Album, error) { @@ -14,10 +16,19 @@ func (r *queryResolver) MyAlbums(ctx context.Context, filter *models.Filter, onl return nil, auth.ErrUnauthorized } - query := r.Database.Where("owner_id = ?", user.ID) + if err := user.FillAlbums(r.Database); err != nil { + return nil, err + } + + userAlbumIDs := make([]int, len(user.Albums)) + for i, album := range user.Albums { + userAlbumIDs[i] = album.ID + } + + query := r.Database.Model(models.Album{}).Where("id IN (?)", userAlbumIDs) if onlyRoot != nil && *onlyRoot == true { - query = query.Where("parent_album_id = (?)", r.Database.Model(&models.Album{}).Select("id").Where("parent_album_id IS NULL AND owner_id = ?", user.ID)) + query = query.Where("parent_album_id = (?)", r.Database.Model(&models.Album{}).Select("id").Where("parent_album_id IS NULL")) } if showEmpty == nil || *showEmpty == false { @@ -33,7 +44,7 @@ func (r *queryResolver) MyAlbums(ctx context.Context, filter *models.Filter, onl query = filter.FormatSQL(query) var albums []*models.Album - if err := query.Find(&albums).Error; err != nil { + if err := query.Scan(&albums).Error; err != nil { return nil, err } @@ -47,10 +58,22 @@ func (r *queryResolver) Album(ctx context.Context, id int) (*models.Album, error } var album models.Album - if err := r.Database.Where("owner_id = ?", user.ID).First(&album, id).Error; err != nil { + if err := r.Database.First(&album, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("album not found") + } return nil, err } + ownsAlbum, err := user.OwnsAlbum(r.Database, &album) + if err != nil { + return nil, err + } + + if !ownsAlbum { + return nil, errors.New("forbidden") + } + return &album, nil } @@ -154,8 +177,24 @@ func (r *albumResolver) Path(ctx context.Context, obj *models.Album) ([]*models. UNION SELECT parent.* FROM path_albums child JOIN albums parent ON parent.id = child.parent_album_id ) - SELECT * FROM path_albums WHERE id != ? AND owner_id = ? - `, obj.ID, obj.ID, user.ID).Scan(&album_path).Error + SELECT * FROM path_albums WHERE id != ? + `, obj.ID, obj.ID).Scan(&album_path).Error + + // Make sure to only return albums this user owns + for i := len(album_path) - 1; i >= 0; i-- { + album := album_path[i] + + owns, err := user.OwnsAlbum(r.Database, album) + if err != nil { + return nil, err + } + + if !owns { + album_path = album_path[i+1:] + break + } + + } if err != nil { return nil, err diff --git a/api/graphql/resolvers/media.go b/api/graphql/resolvers/media.go index a6b82313..f1e3d667 100644 --- a/api/graphql/resolvers/media.go +++ b/api/graphql/resolvers/media.go @@ -18,11 +18,20 @@ func (r *queryResolver) MyMedia(ctx context.Context, filter *models.Filter) ([]* return nil, errors.New("unauthorized") } + if err := user.FillAlbums(r.Database); err != nil { + return nil, err + } + + userAlbumIDs := make([]int, len(user.Albums)) + for i, album := range user.Albums { + userAlbumIDs[i] = album.ID + } + var media []*models.Media query := r.Database. Joins("Album"). - Where("albums.owner_id = ?", user.ID). + Where("albums.id IN (?)", userAlbumIDs). Where("media.id IN (?)", r.Database.Model(&models.MediaURL{}).Select("id").Where("media_url.media_id = media.id")) query = filter.FormatSQL(query) diff --git a/api/graphql/resolvers/scanner.go b/api/graphql/resolvers/scanner.go index 734eb477..179f1be3 100644 --- a/api/graphql/resolvers/scanner.go +++ b/api/graphql/resolvers/scanner.go @@ -28,7 +28,6 @@ func (r *mutationResolver) ScanAll(ctx context.Context) (*models.ScannerResult, func (r *mutationResolver) ScanUser(ctx context.Context, userID int) (*models.ScannerResult, error) { var user models.User - if err := r.Database.First(&user, userID).Error; err != nil { return nil, errors.Wrap(err, "get user from database") } diff --git a/api/graphql/resolvers/user.go b/api/graphql/resolvers/user.go index f6d594d5..50dc24bc 100644 --- a/api/graphql/resolvers/user.go +++ b/api/graphql/resolvers/user.go @@ -5,6 +5,7 @@ import ( "github.com/photoview/photoview/api/graphql/auth" "github.com/photoview/photoview/api/graphql/models" + "github.com/photoview/photoview/api/scanner" "github.com/pkg/errors" "golang.org/x/crypto/bcrypt" "gorm.io/gorm" @@ -84,6 +85,11 @@ func (r *mutationResolver) InitialSetupWizard(ctx context.Context, username stri return err } + _, err = scanner.NewRootAlbum(tx, rootPath, user) + if err != nil { + return err + } + token, err = user.GenerateAccessToken(tx) if err != nil { return err diff --git a/api/routes/authenticate_media.go b/api/routes/authenticate_media.go index 8e42f413..e29d8cc0 100644 --- a/api/routes/authenticate_media.go +++ b/api/routes/authenticate_media.go @@ -19,7 +19,12 @@ func authenticateMedia(media *models.Media, db *gorm.DB, r *http.Request) (succe return false, "internal server error", http.StatusInternalServerError, err } - if album.OwnerID != user.ID { + ownsAlbum, err := user.OwnsAlbum(db, &album) + if err != nil { + return false, "internal server error", http.StatusInternalServerError, err + } + + if !ownsAlbum { return false, "invalid credentials", http.StatusForbidden, nil } } else { diff --git a/api/scanner/cleanup_media.go b/api/scanner/cleanup_media.go index ddf06492..e89b2d13 100644 --- a/api/scanner/cleanup_media.go +++ b/api/scanner/cleanup_media.go @@ -4,7 +4,6 @@ import ( "os" "path" "strconv" - "strings" "github.com/photoview/photoview/api/graphql/models" "github.com/pkg/errors" @@ -59,28 +58,32 @@ func deleteOldUserAlbums(db *gorm.DB, scannedAlbums []*models.Album, user *model return nil } - albumPaths := make([]interface{}, len(scannedAlbums)) + scannedAlbumIDs := make([]interface{}, len(scannedAlbums)) for i, album := range scannedAlbums { - albumPaths[i] = album.Path + scannedAlbumIDs[i] = album.ID } // Delete old albums - album_args := make([]interface{}, 0) - album_args = append(album_args, user.ID) - album_args = append(album_args, albumPaths...) - var albums []models.Album - albums_questions := strings.Repeat("MD5(?),", len(albumPaths))[:len(albumPaths)*7-1] - if err := db.Where("owner_id = ? AND path_hash NOT IN ("+albums_questions+")", album_args...).Find(&albums).Error; err != nil { + userAlbumIDs := make([]int, len(user.Albums)) + for i, album := range user.Albums { + userAlbumIDs[i] = album.ID + } + + query := db. + Where("id IN (?)", userAlbumIDs). + Where("id NOT IN (?)", scannedAlbumIDs) + + if err := query.Find(&albums).Error; err != nil { return []error{errors.Wrap(err, "get albums to be deleted from database")} } deleteErrors := make([]error, 0) - albumIDs := make([]int, 0) - for _, album := range albums { - albumIDs = append(albumIDs, album.ID) + deleteAlbumIDs := make([]int, len(albums)) + for i, album := range albums { + deleteAlbumIDs[i] = album.ID cachePath := path.Join(PhotoCache(), strconv.Itoa(int(album.ID))) err := os.RemoveAll(cachePath) if err != nil { @@ -88,7 +91,7 @@ func deleteOldUserAlbums(db *gorm.DB, scannedAlbums []*models.Album, user *model } } - if err := db.Where("id IN ?", albumIDs).Delete(models.Album{}).Error; err != nil { + if err := db.Where("id IN ?", deleteAlbumIDs).Delete(models.Album{}).Error; err != nil { ScannerError("Could not delete old albums from database:\n%s\n", err) deleteErrors = append(deleteErrors, errors.Wrap(err, "delete old albums from database")) } diff --git a/api/scanner/scanner_album.go b/api/scanner/scanner_album.go index 38757da4..11decfe3 100644 --- a/api/scanner/scanner_album.go +++ b/api/scanner/scanner_album.go @@ -13,6 +13,25 @@ import ( "gorm.io/gorm" ) +func NewRootAlbum(db *gorm.DB, rootPath string, owner *models.User) (*models.Album, error) { + + owners := []models.User{ + *owner, + } + + album := models.Album{ + Title: path.Base(rootPath), + Path: rootPath, + Owners: owners, + } + + if err := db.Create(&album).Error; err != nil { + return nil, err + } + + return &album, nil +} + func scanAlbum(album *models.Album, cache *AlbumScannerCache, db *gorm.DB) { album_notify_key := utils.GenerateToken() diff --git a/api/scanner/scanner_user.go b/api/scanner/scanner_user.go index ca1b157d..eb4ba153 100644 --- a/api/scanner/scanner_user.go +++ b/api/scanner/scanner_user.go @@ -17,16 +17,25 @@ import ( func findAlbumsForUser(db *gorm.DB, user *models.User, album_cache *AlbumScannerCache) ([]*models.Album, []error) { + if err := user.FillAlbums(db); err != nil { + return nil, []error{err} + } + + userAlbumIDs := make([]int, len(user.Albums)) + for i, album := range user.Albums { + userAlbumIDs[i] = album.ID + } + var userRootAlbums []*models.Album - if err := db.Model(&user).Association("Albums").Find(&userRootAlbums); err != nil { - return nil, []error{errors.Wrapf(err, "get albums of user (%s)", user.Username)} + if err := db.Where("id IN (?)", userAlbumIDs).Where("parent_album_id IS NULL").Find(&userRootAlbums).Error; err != nil { + return nil, []error{err} } scanErrors := make([]error, 0) type scanInfo struct { - path string - parentID *int + path string + parent *models.Album } scanQueue := list.New() @@ -41,8 +50,8 @@ func findAlbumsForUser(db *gorm.DB, user *models.User, album_cache *AlbumScanner } } else { scanQueue.PushBack(scanInfo{ - path: album.Path, - parentID: nil, + path: album.Path, + parent: nil, }) } } @@ -54,7 +63,7 @@ func findAlbumsForUser(db *gorm.DB, user *models.User, album_cache *AlbumScanner scanQueue.Remove(scanQueue.Front()) albumPath := albumInfo.path - albumParentID := albumInfo.parentID + albumParent := albumInfo.parent // Read path dirContent, err := ioutil.ReadDir(albumPath) @@ -64,26 +73,50 @@ func findAlbumsForUser(db *gorm.DB, user *models.User, album_cache *AlbumScanner } // Will become new album or album from db - var album models.Album + var album *models.Album transErr := db.Transaction(func(tx *gorm.DB) error { log.Printf("Scanning directory: %s", albumPath) - // Make album if not exists - albumTitle := path.Base(albumPath) - - err = tx.FirstOrCreate(&album, models.Album{ - Title: albumTitle, - ParentAlbumID: albumParentID, - OwnerID: user.ID, - Path: albumPath, - }).Error - - if err != nil { - return errors.Wrap(err, "insert album into database") + // check if album already exists + var albumResult []models.Album + result := tx.Where("path_hash = md5(?)", albumPath).Find(&albumResult) + if result.Error != nil { + return result.Error } - userAlbums = append(userAlbums, &album) + // album does not exist, create new + if len(albumResult) == 0 { + albumTitle := path.Base(albumPath) + + var albumParentID *int + parentOwners := make([]models.User, 0) + if albumParent != nil { + albumParentID = &albumParent.ID + + if err := db.Model(&albumParent).Association("Owners").Find(&parentOwners); err != nil { + return err + } + } + + album = &models.Album{ + Title: albumTitle, + ParentAlbumID: albumParentID, + Path: albumPath, + } + + if err := tx.Create(&album).Error; err != nil { + return errors.Wrap(err, "insert album into database") + } + + if err := tx.Model(&album).Association("Owners").Append(parentOwners); err != nil { + return errors.Wrap(err, "add owners to album") + } + } else { + album = &albumResult[0] + } + + userAlbums = append(userAlbums, album) return nil }) @@ -104,8 +137,8 @@ func findAlbumsForUser(db *gorm.DB, user *models.User, album_cache *AlbumScanner if item.IsDir() && directoryContainsPhotos(subalbumPath, album_cache) { scanQueue.PushBack(scanInfo{ - path: subalbumPath, - parentID: &album.ID, + path: subalbumPath, + parent: album, }) } } diff --git a/ui/src/Pages/SettingsPage/UsersTable.js b/ui/src/Pages/SettingsPage/UsersTable.js index ea88ecea..1dc187e4 100644 --- a/ui/src/Pages/SettingsPage/UsersTable.js +++ b/ui/src/Pages/SettingsPage/UsersTable.js @@ -11,7 +11,7 @@ const USERS_QUERY = gql` user { id username - rootPath + # rootPath admin } } From 96546f6556c55aab61b6913f943144b2fa5a56de Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Sun, 27 Dec 2020 20:07:54 +0100 Subject: [PATCH 3/8] Add resolvers for user albums and root_albums --- api/gqlgen.yml | 3 + api/graphql/generated.go | 168 ++++++++++++++++-------- api/graphql/resolvers/user.go | 33 +++++ api/graphql/schema.graphql | 4 +- ui/src/Pages/SettingsPage/UsersTable.js | 4 + 5 files changed, 155 insertions(+), 57 deletions(-) diff --git a/api/gqlgen.yml b/api/gqlgen.yml index 0f0c5e83..89eff49b 100644 --- a/api/gqlgen.yml +++ b/api/gqlgen.yml @@ -24,6 +24,9 @@ models: model: github.com/99designs/gqlgen/graphql.IntID User: model: github.com/photoview/photoview/api/graphql/models.User + fields: + albums: + resolver: true Media: model: github.com/photoview/photoview/api/graphql/models.Media MediaURL: diff --git a/api/graphql/generated.go b/api/graphql/generated.go index 65985d37..32128744 100644 --- a/api/graphql/generated.go +++ b/api/graphql/generated.go @@ -44,6 +44,7 @@ type ResolverRoot interface { Query() QueryResolver ShareToken() ShareTokenResolver Subscription() SubscriptionResolver + User() UserResolver } type DirectiveRoot struct { @@ -191,10 +192,11 @@ type ComplexityRoot struct { } User struct { - Admin func(childComplexity int) int - Albums func(childComplexity int) int - ID func(childComplexity int) int - Username func(childComplexity int) int + Admin func(childComplexity int) int + Albums func(childComplexity int) int + ID func(childComplexity int) int + RootAlbums func(childComplexity int) int + Username func(childComplexity int) int } VideoMetadata struct { @@ -268,6 +270,10 @@ type ShareTokenResolver interface { type SubscriptionResolver interface { Notification(ctx context.Context) (<-chan *models.Notification, error) } +type UserResolver interface { + Albums(ctx context.Context, obj *models.User) ([]*models.Album, error) + RootAlbums(ctx context.Context, obj *models.User) ([]*models.Album, error) +} type executableSchema struct { resolvers ResolverRoot @@ -1104,6 +1110,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.User.ID(childComplexity), true + case "User.rootAlbums": + if e.complexity.User.RootAlbums == nil { + break + } + + return e.complexity.User.RootAlbums(childComplexity), true + case "User.username": if e.complexity.User.Username == nil { break @@ -1436,8 +1449,10 @@ type User { username: String! #albums: [Album] # rootPath: String! @isAdmin - "Top level albums owned by this user" + "All albums owned by this user" albums: [Album!]! @isAdmin + "Top level albums owned by this user" + rootAlbums: [Album!]! @isAdmin admin: Boolean! #shareTokens: [ShareToken] } @@ -5931,15 +5946,15 @@ func (ec *executionContext) _User_albums(ctx context.Context, field graphql.Coll Object: "User", Field: field, Args: nil, - IsMethod: false, - IsResolver: false, + IsMethod: true, + IsResolver: true, } ctx = graphql.WithFieldContext(ctx, fc) resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { directive0 := func(rctx context.Context) (interface{}, error) { ctx = rctx // use context from middleware stack in children - return obj.Albums, nil + return ec.resolvers.User().Albums(rctx, obj) } directive1 := func(ctx context.Context) (interface{}, error) { if ec.directives.IsAdmin == nil { @@ -5955,10 +5970,10 @@ func (ec *executionContext) _User_albums(ctx context.Context, field graphql.Coll if tmp == nil { return nil, nil } - if data, ok := tmp.([]models.Album); ok { + if data, ok := tmp.([]*models.Album); ok { return data, nil } - return nil, fmt.Errorf(`unexpected type %T from directive, should be []github.com/photoview/photoview/api/graphql/models.Album`, tmp) + return nil, fmt.Errorf(`unexpected type %T from directive, should be []*github.com/photoview/photoview/api/graphql/models.Album`, tmp) }) if err != nil { ec.Error(ctx, err) @@ -5970,9 +5985,64 @@ func (ec *executionContext) _User_albums(ctx context.Context, field graphql.Coll } return graphql.Null } - res := resTmp.([]models.Album) + res := resTmp.([]*models.Album) fc.Result = res - return ec.marshalNAlbum2ᚕgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐAlbumᚄ(ctx, field.Selections, res) + return ec.marshalNAlbum2ᚕᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐAlbumᚄ(ctx, field.Selections, res) +} + +func (ec *executionContext) _User_rootAlbums(ctx context.Context, field graphql.CollectedField, obj *models.User) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "User", + Field: field, + Args: nil, + IsMethod: true, + IsResolver: true, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + directive0 := func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.User().RootAlbums(rctx, obj) + } + directive1 := func(ctx context.Context) (interface{}, error) { + if ec.directives.IsAdmin == nil { + return nil, errors.New("directive isAdmin is not implemented") + } + return ec.directives.IsAdmin(ctx, obj, directive0) + } + + tmp, err := directive1(rctx) + if err != nil { + return nil, graphql.ErrorOnPath(ctx, err) + } + if tmp == nil { + return nil, nil + } + if data, ok := tmp.([]*models.Album); ok { + return data, nil + } + return nil, fmt.Errorf(`unexpected type %T from directive, should be []*github.com/photoview/photoview/api/graphql/models.Album`, tmp) + }) + 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.([]*models.Album) + fc.Result = res + return ec.marshalNAlbum2ᚕᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐAlbumᚄ(ctx, field.Selections, res) } func (ec *executionContext) _User_admin(ctx context.Context, field graphql.CollectedField, obj *models.User) (ret graphql.Marshaler) { @@ -8425,22 +8495,45 @@ func (ec *executionContext) _User(ctx context.Context, sel ast.SelectionSet, obj case "id": out.Values[i] = ec._User_id(ctx, field, obj) if out.Values[i] == graphql.Null { - invalids++ + atomic.AddUint32(&invalids, 1) } case "username": out.Values[i] = ec._User_username(ctx, field, obj) if out.Values[i] == graphql.Null { - invalids++ + atomic.AddUint32(&invalids, 1) } case "albums": - out.Values[i] = ec._User_albums(ctx, field, obj) - if out.Values[i] == graphql.Null { - invalids++ - } + field := field + out.Concurrently(i, func() (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._User_albums(ctx, field, obj) + if res == graphql.Null { + atomic.AddUint32(&invalids, 1) + } + return res + }) + case "rootAlbums": + field := field + out.Concurrently(i, func() (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._User_rootAlbums(ctx, field, obj) + if res == graphql.Null { + atomic.AddUint32(&invalids, 1) + } + return res + }) case "admin": out.Values[i] = ec._User_admin(ctx, field, obj) if out.Values[i] == graphql.Null { - invalids++ + atomic.AddUint32(&invalids, 1) } default: panic("unknown field " + strconv.Quote(field.Name)) @@ -8759,43 +8852,6 @@ func (ec *executionContext) marshalNAlbum2githubᚗcomᚋphotoviewᚋphotoview return ec._Album(ctx, sel, &v) } -func (ec *executionContext) marshalNAlbum2ᚕgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐAlbumᚄ(ctx context.Context, sel ast.SelectionSet, v []models.Album) graphql.Marshaler { - ret := make(graphql.Array, len(v)) - var wg sync.WaitGroup - isLen1 := len(v) == 1 - if !isLen1 { - wg.Add(len(v)) - } - for i := range v { - i := i - fc := &graphql.FieldContext{ - Index: &i, - Result: &v[i], - } - ctx := graphql.WithFieldContext(ctx, fc) - f := func(i int) { - defer func() { - if r := recover(); r != nil { - ec.Error(ctx, ec.Recover(ctx, r)) - ret = nil - } - }() - if !isLen1 { - defer wg.Done() - } - ret[i] = ec.marshalNAlbum2githubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐAlbum(ctx, sel, v[i]) - } - if isLen1 { - f(i) - } else { - go f(i) - } - - } - wg.Wait() - return ret -} - func (ec *executionContext) marshalNAlbum2ᚕᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐAlbumᚄ(ctx context.Context, sel ast.SelectionSet, v []*models.Album) graphql.Marshaler { ret := make(graphql.Array, len(v)) var wg sync.WaitGroup diff --git a/api/graphql/resolvers/user.go b/api/graphql/resolvers/user.go index 50dc24bc..65029888 100644 --- a/api/graphql/resolvers/user.go +++ b/api/graphql/resolvers/user.go @@ -3,6 +3,7 @@ package resolvers import ( "context" + api "github.com/photoview/photoview/api/graphql" "github.com/photoview/photoview/api/graphql/auth" "github.com/photoview/photoview/api/graphql/models" "github.com/photoview/photoview/api/scanner" @@ -11,6 +12,14 @@ import ( "gorm.io/gorm" ) +type userResolver struct { + *Resolver +} + +func (r *Resolver) User() api.UserResolver { + return &userResolver{r} +} + func (r *queryResolver) User(ctx context.Context, filter *models.Filter) ([]*models.User, error) { var users []*models.User @@ -22,6 +31,30 @@ func (r *queryResolver) User(ctx context.Context, filter *models.Filter) ([]*mod return users, nil } +func (r *userResolver) Albums(ctx context.Context, user *models.User) ([]*models.Album, error) { + user.FillAlbums(r.Database) + + pointerAlbums := make([]*models.Album, len(user.Albums)) + for i, album := range user.Albums { + pointerAlbums[i] = &album + } + + return pointerAlbums, nil +} + +func (r *userResolver) RootAlbums(ctx context.Context, user *models.User) (albums []*models.Album, err error) { + + err = r.Database.Model(&user). + Where("albums.parent_album_id NOT IN (?)", + r.Database.Table("user_albums"). + Select("albums.id"). + Joins("JOIN albums ON albums.id = user_albums.album_id AND user_albums.user_id = ?", user.ID), + ).Or("albums.parent_album_id IS NULL"). + Association("Albums").Find(&albums) + + return +} + func (r *queryResolver) MyUser(ctx context.Context) (*models.User, error) { user := auth.UserFromContext(ctx) diff --git a/api/graphql/schema.graphql b/api/graphql/schema.graphql index 38bd197c..f4dcf526 100644 --- a/api/graphql/schema.graphql +++ b/api/graphql/schema.graphql @@ -172,8 +172,10 @@ type User { username: String! #albums: [Album] # rootPath: String! @isAdmin - "Top level albums owned by this user" + "All albums owned by this user" albums: [Album!]! @isAdmin + "Top level albums owned by this user" + rootAlbums: [Album!]! @isAdmin admin: Boolean! #shareTokens: [ShareToken] } diff --git a/ui/src/Pages/SettingsPage/UsersTable.js b/ui/src/Pages/SettingsPage/UsersTable.js index 1dc187e4..9c86aa09 100644 --- a/ui/src/Pages/SettingsPage/UsersTable.js +++ b/ui/src/Pages/SettingsPage/UsersTable.js @@ -13,6 +13,10 @@ const USERS_QUERY = gql` username # rootPath admin + albums { + id + filePath + } } } ` From 6e2773cc657c15b90f2a7f4b2ffa6459548811ac Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Sun, 27 Dec 2020 22:32:46 +0100 Subject: [PATCH 4/8] Add UI for multi root users --- ui/src/Pages/SettingsPage/SettingsPage.js | 2 +- ui/src/Pages/SettingsPage/UserRow.js | 298 ------------------ .../SettingsPage/{ => Users}/AddUserRow.js | 0 .../Pages/SettingsPage/Users/EditUserRow.js | 158 ++++++++++ .../SettingsPage/Users/UserChangePassword.js | 66 ++++ ui/src/Pages/SettingsPage/Users/UserRow.js | 121 +++++++ .../SettingsPage/{ => Users}/UsersTable.js | 4 +- .../Pages/SettingsPage/Users/ViewUserRow.js | 109 +++++++ 8 files changed, 457 insertions(+), 301 deletions(-) delete mode 100644 ui/src/Pages/SettingsPage/UserRow.js rename ui/src/Pages/SettingsPage/{ => Users}/AddUserRow.js (100%) create mode 100644 ui/src/Pages/SettingsPage/Users/EditUserRow.js create mode 100644 ui/src/Pages/SettingsPage/Users/UserChangePassword.js create mode 100644 ui/src/Pages/SettingsPage/Users/UserRow.js rename ui/src/Pages/SettingsPage/{ => Users}/UsersTable.js (96%) create mode 100644 ui/src/Pages/SettingsPage/Users/ViewUserRow.js diff --git a/ui/src/Pages/SettingsPage/SettingsPage.js b/ui/src/Pages/SettingsPage/SettingsPage.js index abd73e05..2c815f2e 100644 --- a/ui/src/Pages/SettingsPage/SettingsPage.js +++ b/ui/src/Pages/SettingsPage/SettingsPage.js @@ -4,7 +4,7 @@ import styled from 'styled-components' import Layout from '../../Layout' import ScannerSection from './ScannerSection' -import UsersTable from './UsersTable' +import UsersTable from './Users/UsersTable' export const SectionTitle = styled.h2` margin-top: ${({ nospace }) => (nospace ? '0' : '1.4em')} !important; diff --git a/ui/src/Pages/SettingsPage/UserRow.js b/ui/src/Pages/SettingsPage/UserRow.js deleted file mode 100644 index 0c807e1d..00000000 --- a/ui/src/Pages/SettingsPage/UserRow.js +++ /dev/null @@ -1,298 +0,0 @@ -import PropTypes from 'prop-types' -import React, { useState } from 'react' -import { gql, useMutation } from '@apollo/client' -import { - Button, - Checkbox, - Form, - Icon, - Input, - Modal, - Table, -} from 'semantic-ui-react' - -const updateUserMutation = gql` - mutation updateUser( - $id: ID! - $username: String - $rootPath: String - $admin: Boolean - ) { - updateUser( - id: $id - username: $username - rootPath: $rootPath - admin: $admin - ) { - id - username - rootPath - admin - } - } -` - -const deleteUserMutation = gql` - mutation deleteUser($id: ID!) { - deleteUser(id: $id) { - id - username - } - } -` - -const changeUserPasswordMutation = gql` - mutation changeUserPassword($userId: ID!, $password: String!) { - updateUser(id: $userId, password: $password) { - id - } - } -` - -const scanUserMutation = gql` - mutation scanUser($userId: ID!) { - scanUser(userId: $userId) { - success - } - } -` - -const ChangePasswordModal = ({ onClose, user, ...props }) => { - const [passwordInput, setPasswordInput] = useState('') - - const [changePassword] = useMutation(changeUserPasswordMutation, { - onCompleted: () => { - onClose && onClose() - }, - }) - - return ( - - Change password - -

- Change password for {user.username} -

-
- - - setPasswordInput(e.target.value)} - type="password" - /> - -
-
- - - - -
- ) -} - -ChangePasswordModal.propTypes = { - onClose: PropTypes.func, - user: PropTypes.object.isRequired, -} - -const UserRow = ({ user, refetchUsers }) => { - const [state, setState] = useState({ - ...user, - editing: false, - }) - - const [showConfirmDelete, setConfirmDelete] = useState(false) - const [showChangePassword, setChangePassword] = useState(false) - - function updateInput(event, key) { - setState({ - ...state, - [key]: event.target.value, - }) - } - - const [updateUser, { loading: updateUserLoading }] = useMutation( - updateUserMutation, - { - onCompleted: data => { - setState({ - ...data.updateUser, - editing: false, - }) - refetchUsers() - }, - } - ) - - const [deleteUser] = useMutation(deleteUserMutation, { - onCompleted: () => { - refetchUsers() - }, - }) - - const [scanUser, { called: scanUserCalled }] = useMutation(scanUserMutation, { - onCompleted: () => { - refetchUsers() - }, - }) - - if (state.editing) { - return ( - - - updateInput(e, 'username')} - /> - - - updateInput(e, 'rootPath')} - /> - - - { - setState({ - ...state, - admin: data.checked, - }) - }} - /> - - - - - - - - - ) - } - - return ( - - {user.username} - {user.rootPath} - - {user.admin ? : null} - - - - - - - setChangePassword(false)} - /> - - - Delete user - -

- {`Are you sure, you want to delete `} - {user.username}? -

-

{`This action cannot be undone`}

-
- - - - -
-
-
-
- ) -} - -UserRow.propTypes = { - user: PropTypes.object.isRequired, - refetchUsers: PropTypes.func.isRequired, -} - -export default UserRow diff --git a/ui/src/Pages/SettingsPage/AddUserRow.js b/ui/src/Pages/SettingsPage/Users/AddUserRow.js similarity index 100% rename from ui/src/Pages/SettingsPage/AddUserRow.js rename to ui/src/Pages/SettingsPage/Users/AddUserRow.js diff --git a/ui/src/Pages/SettingsPage/Users/EditUserRow.js b/ui/src/Pages/SettingsPage/Users/EditUserRow.js new file mode 100644 index 00000000..ac21e939 --- /dev/null +++ b/ui/src/Pages/SettingsPage/Users/EditUserRow.js @@ -0,0 +1,158 @@ +import React from 'react' +import PropTypes from 'prop-types' +import styled from 'styled-components' +import { Button, Checkbox, Icon, Input, Table } from 'semantic-ui-react' +import { UserRowProps } from './UserRow' + +const RootPathListItem = styled.li` + display: flex; + justify-content: space-between; + align-items: center; +` + +const EditRootPath = ({ filePath, removePath }) => ( + + {filePath} + + +) + +EditRootPath.propTypes = { + filePath: PropTypes.string.isRequired, + removePath: PropTypes.func.isRequired, +} + +const NewRootPathInput = styled(Input)` + width: 100%; + margin-top: 24px; +` + +const EditNewRootPath = ({ state, updateInput }) => ( +
  • + updateInput(e, 'rootPath')} + action={{ + positive: true, + icon: 'add', + content: 'Add', + }} + /> +
  • +) + +EditNewRootPath.propTypes = { + state: PropTypes.object.isRequired, + updateInput: PropTypes.func.isRequired, +} + +const RootPathList = styled.ul` + margin: 0; + padding: 0; + list-style: none; +` + +const EditRootPaths = ({ user, state, updateInput }) => { + const editRows = user.rootAlbums.map(album => ( + {}} + /> + )) + + return ( + + {editRows} + + + ) +} + +EditRootPaths.propTypes = { + updateInput: PropTypes.func.isRequired, + user: PropTypes.object.isRequired, + state: PropTypes.object.isRequired, +} + +const EditUserRow = ({ + user, + state, + setState, + updateUser, + updateUserLoading, +}) => { + function updateInput(event, key) { + setState(state => ({ + ...state, + [key]: event.target.value, + })) + } + + return ( + + + updateInput(e, 'username')} + /> + + + + + + { + setState({ + ...state, + admin: data.checked, + }) + }} + /> + + + + + + + + + ) +} + +EditUserRow.propTypes = UserRowProps + +export default EditUserRow diff --git a/ui/src/Pages/SettingsPage/Users/UserChangePassword.js b/ui/src/Pages/SettingsPage/Users/UserChangePassword.js new file mode 100644 index 00000000..39d9b9a8 --- /dev/null +++ b/ui/src/Pages/SettingsPage/Users/UserChangePassword.js @@ -0,0 +1,66 @@ +import React, { useState } from 'react' +import PropTypes from 'prop-types' +import { gql, useMutation } from '@apollo/client' +import { Button, Form, Input, Modal } from 'semantic-ui-react' + +const changeUserPasswordMutation = gql` + mutation changeUserPassword($userId: ID!, $password: String!) { + updateUser(id: $userId, password: $password) { + id + } + } +` + +const ChangePasswordModal = ({ onClose, user, ...props }) => { + const [passwordInput, setPasswordInput] = useState('') + + const [changePassword] = useMutation(changeUserPasswordMutation, { + onCompleted: () => { + onClose && onClose() + }, + }) + + return ( + + Change password + +

    + Change password for {user.username} +

    +
    + + + setPasswordInput(e.target.value)} + type="password" + /> + +
    +
    + + + + +
    + ) +} + +ChangePasswordModal.propTypes = { + onClose: PropTypes.func, + user: PropTypes.object.isRequired, +} + +export default ChangePasswordModal diff --git a/ui/src/Pages/SettingsPage/Users/UserRow.js b/ui/src/Pages/SettingsPage/Users/UserRow.js new file mode 100644 index 00000000..7a19b14d --- /dev/null +++ b/ui/src/Pages/SettingsPage/Users/UserRow.js @@ -0,0 +1,121 @@ +import PropTypes from 'prop-types' +import React, { useState } from 'react' +import { gql, useMutation } from '@apollo/client' +import EditUserRow from './EditUserRow' +import ViewUserRow from './ViewUserRow' + +const updateUserMutation = gql` + mutation updateUser( + $id: ID! + $username: String + $rootPath: String + $admin: Boolean + ) { + updateUser( + id: $id + username: $username + rootPath: $rootPath + admin: $admin + ) { + id + username + rootPath + admin + } + } +` + +const deleteUserMutation = gql` + mutation deleteUser($id: ID!) { + deleteUser(id: $id) { + id + username + } + } +` + +const scanUserMutation = gql` + mutation scanUser($userId: ID!) { + scanUser(userId: $userId) { + success + } + } +` + +const UserRow = ({ user, refetchUsers }) => { + const [state, setState] = useState({ + ...user, + editing: false, + }) + + const [showConfirmDelete, setConfirmDelete] = useState(false) + const [showChangePassword, setChangePassword] = useState(false) + + const [updateUser, { loading: updateUserLoading }] = useMutation( + updateUserMutation, + { + onCompleted: data => { + setState({ + ...data.updateUser, + editing: false, + }) + refetchUsers() + }, + } + ) + + const [deleteUser] = useMutation(deleteUserMutation, { + onCompleted: () => { + refetchUsers() + }, + }) + + const [scanUser, { called: scanUserCalled }] = useMutation(scanUserMutation, { + onCompleted: () => { + refetchUsers() + }, + }) + + const props = { + user, + state, + setState, + scanUser, + updateUser, + updateUserLoading, + deleteUser, + setChangePassword, + setConfirmDelete, + scanUserCalled, + showChangePassword, + showConfirmDelete, + } + + if (state.editing) { + return + } + + return +} + +UserRow.propTypes = { + user: PropTypes.object.isRequired, + refetchUsers: PropTypes.func.isRequired, +} + +export const UserRowProps = { + user: PropTypes.object.isRequired, + state: PropTypes.object.isRequired, + setState: PropTypes.func.isRequired, + scanUser: PropTypes.func.isRequired, + updateUser: PropTypes.func.isRequired, + updateUserLoading: PropTypes.bool.isRequired, + deleteUser: PropTypes.func.isRequired, + setChangePassword: PropTypes.func.isRequired, + setConfirmDelete: PropTypes.func.isRequired, + scanUserCalled: PropTypes.func.isRequired, + showChangePassword: PropTypes.func.isRequired, + showConfirmDelete: PropTypes.func.isRequired, +} + +export default UserRow diff --git a/ui/src/Pages/SettingsPage/UsersTable.js b/ui/src/Pages/SettingsPage/Users/UsersTable.js similarity index 96% rename from ui/src/Pages/SettingsPage/UsersTable.js rename to ui/src/Pages/SettingsPage/Users/UsersTable.js index 9c86aa09..fa628d99 100644 --- a/ui/src/Pages/SettingsPage/UsersTable.js +++ b/ui/src/Pages/SettingsPage/Users/UsersTable.js @@ -4,7 +4,7 @@ import { Table, Loader, Button, Icon } from 'semantic-ui-react' import { useQuery, gql } from '@apollo/client' import UserRow from './UserRow' import AddUserRow from './AddUserRow' -import { SectionTitle } from './SettingsPage' +import { SectionTitle } from '../SettingsPage' const USERS_QUERY = gql` query settingsUsersQuery { @@ -13,7 +13,7 @@ const USERS_QUERY = gql` username # rootPath admin - albums { + rootAlbums { id filePath } diff --git a/ui/src/Pages/SettingsPage/Users/ViewUserRow.js b/ui/src/Pages/SettingsPage/Users/ViewUserRow.js new file mode 100644 index 00000000..d90432ac --- /dev/null +++ b/ui/src/Pages/SettingsPage/Users/ViewUserRow.js @@ -0,0 +1,109 @@ +import React from 'react' +import { Button, Icon, Table, Modal } from 'semantic-ui-react' +import styled from 'styled-components' +import ChangePasswordModal from './UserChangePassword' +import { UserRowProps } from './UserRow' + +const PathList = styled.ul` + margin: 0; + padding: 0 0 0 12px; + list-style: none; +` + +const ViewUserRow = ({ + user, + state, + setState, + scanUser, + deleteUser, + setChangePassword, + setConfirmDelete, + scanUserCalled, + showChangePassword, + showConfirmDelete, +}) => { + const paths = ( + + {user.rootAlbums.map(album => ( +
  • {album.filePath}
  • + ))} +
    + ) + + return ( + + {user.username} + {paths} + + {user.admin ? : null} + + + + + + + setChangePassword(false)} + /> + + + Delete user + +

    + {`Are you sure, you want to delete `} + {user.username}? +

    +

    {`This action cannot be undone`}

    +
    + + + + +
    +
    +
    +
    + ) +} + +ViewUserRow.propTypes = UserRowProps + +export default ViewUserRow From c198e68dafc758246c65aac04279f0faebce02e4 Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Wed, 30 Dec 2020 18:36:26 +0100 Subject: [PATCH 5/8] Hook up UI to add and remove multiple root paths for each user --- api/graphql/generated.go | 202 ++++++++++++++++++ api/graphql/resolvers/user.go | 36 ++++ api/graphql/schema.graphql | 4 + .../Pages/SettingsPage/Users/EditUserRow.js | 90 +------- .../Users/EditUserRowRootPaths.js | 134 ++++++++++++ ui/src/Pages/SettingsPage/Users/UserRow.js | 1 + ui/src/Pages/SettingsPage/Users/UsersTable.js | 2 +- .../Pages/SettingsPage/Users/ViewUserRow.js | 4 +- 8 files changed, 387 insertions(+), 86 deletions(-) create mode 100644 ui/src/Pages/SettingsPage/Users/EditUserRowRootPaths.js diff --git a/api/graphql/generated.go b/api/graphql/generated.go index 32128744..9d7744ae 100644 --- a/api/graphql/generated.go +++ b/api/graphql/generated.go @@ -129,6 +129,8 @@ type ComplexityRoot struct { ShareAlbum func(childComplexity int, albumID int, expire *time.Time, password *string) int ShareMedia func(childComplexity int, mediaID int, expire *time.Time, password *string) int UpdateUser func(childComplexity int, id int, username *string, password *string, admin *bool) int + UserAddRootPath func(childComplexity int, id int, rootPath string) int + UserRemoveRootAlbum func(childComplexity int, userID int, albumID int) int } Notification struct { @@ -246,6 +248,8 @@ type MutationResolver interface { UpdateUser(ctx context.Context, id int, username *string, password *string, admin *bool) (*models.User, error) CreateUser(ctx context.Context, username string, password *string, admin bool) (*models.User, error) DeleteUser(ctx context.Context, id int) (*models.User, error) + UserAddRootPath(ctx context.Context, id int, rootPath string) (*models.Album, error) + UserRemoveRootAlbum(ctx context.Context, userID int, albumID int) (*models.Album, error) SetPeriodicScanInterval(ctx context.Context, interval int) (int, error) SetScannerConcurrentWorkers(ctx context.Context, workers int) (int, error) } @@ -771,6 +775,30 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Mutation.UpdateUser(childComplexity, args["id"].(int), args["username"].(*string), args["password"].(*string), args["admin"].(*bool)), true + case "Mutation.userAddRootPath": + if e.complexity.Mutation.UserAddRootPath == nil { + break + } + + args, err := ec.field_Mutation_userAddRootPath_args(context.TODO(), rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.UserAddRootPath(childComplexity, args["id"].(int), args["rootPath"].(string)), true + + case "Mutation.userRemoveRootAlbum": + if e.complexity.Mutation.UserRemoveRootAlbum == nil { + break + } + + args, err := ec.field_Mutation_userRemoveRootAlbum_args(context.TODO(), rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.UserRemoveRootAlbum(childComplexity, args["userId"].(int), args["albumId"].(int)), true + case "Notification.content": if e.complexity.Notification.Content == nil { break @@ -1372,6 +1400,10 @@ type Mutation { ): User @isAdmin deleteUser(id: ID!): User @isAdmin + "Add a root path from where to look for media for the given user" + userAddRootPath(id: ID!, rootPath: String!): Album @isAdmin + userRemoveRootAlbum(userId: ID!, albumId: ID!): Album @isAdmin + """ Set how often, in seconds, the server should automatically scan for new media, a value of 0 will disable periodic scans @@ -1935,6 +1967,54 @@ func (ec *executionContext) field_Mutation_updateUser_args(ctx context.Context, return args, nil } +func (ec *executionContext) field_Mutation_userAddRootPath_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { + var err error + args := map[string]interface{}{} + var arg0 int + if tmp, ok := rawArgs["id"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("id")) + arg0, err = ec.unmarshalNID2int(ctx, tmp) + if err != nil { + return nil, err + } + } + args["id"] = arg0 + var arg1 string + if tmp, ok := rawArgs["rootPath"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("rootPath")) + arg1, err = ec.unmarshalNString2string(ctx, tmp) + if err != nil { + return nil, err + } + } + args["rootPath"] = arg1 + return args, nil +} + +func (ec *executionContext) field_Mutation_userRemoveRootAlbum_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { + var err error + args := map[string]interface{}{} + var arg0 int + if tmp, ok := rawArgs["userId"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("userId")) + arg0, err = ec.unmarshalNID2int(ctx, tmp) + if err != nil { + return nil, err + } + } + args["userId"] = arg0 + var arg1 int + if tmp, ok := rawArgs["albumId"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("albumId")) + arg1, err = ec.unmarshalNID2int(ctx, tmp) + if err != nil { + return nil, err + } + } + args["albumId"] = arg1 + 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{}{} @@ -4236,6 +4316,124 @@ func (ec *executionContext) _Mutation_deleteUser(ctx context.Context, field grap return ec.marshalOUser2ᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐUser(ctx, field.Selections, res) } +func (ec *executionContext) _Mutation_userAddRootPath(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "Mutation", + Field: field, + Args: nil, + IsMethod: true, + IsResolver: true, + } + + ctx = graphql.WithFieldContext(ctx, fc) + rawArgs := field.ArgumentMap(ec.Variables) + args, err := ec.field_Mutation_userAddRootPath_args(ctx, rawArgs) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + fc.Args = args + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + directive0 := func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Mutation().UserAddRootPath(rctx, args["id"].(int), args["rootPath"].(string)) + } + directive1 := func(ctx context.Context) (interface{}, error) { + if ec.directives.IsAdmin == nil { + return nil, errors.New("directive isAdmin is not implemented") + } + return ec.directives.IsAdmin(ctx, nil, directive0) + } + + tmp, err := directive1(rctx) + if err != nil { + return nil, graphql.ErrorOnPath(ctx, err) + } + if tmp == nil { + return nil, nil + } + if data, ok := tmp.(*models.Album); ok { + return data, nil + } + return nil, fmt.Errorf(`unexpected type %T from directive, should be *github.com/photoview/photoview/api/graphql/models.Album`, tmp) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*models.Album) + fc.Result = res + return ec.marshalOAlbum2ᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐAlbum(ctx, field.Selections, res) +} + +func (ec *executionContext) _Mutation_userRemoveRootAlbum(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "Mutation", + Field: field, + Args: nil, + IsMethod: true, + IsResolver: true, + } + + ctx = graphql.WithFieldContext(ctx, fc) + rawArgs := field.ArgumentMap(ec.Variables) + args, err := ec.field_Mutation_userRemoveRootAlbum_args(ctx, rawArgs) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + fc.Args = args + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + directive0 := func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Mutation().UserRemoveRootAlbum(rctx, args["userId"].(int), args["albumId"].(int)) + } + directive1 := func(ctx context.Context) (interface{}, error) { + if ec.directives.IsAdmin == nil { + return nil, errors.New("directive isAdmin is not implemented") + } + return ec.directives.IsAdmin(ctx, nil, directive0) + } + + tmp, err := directive1(rctx) + if err != nil { + return nil, graphql.ErrorOnPath(ctx, err) + } + if tmp == nil { + return nil, nil + } + if data, ok := tmp.(*models.Album); ok { + return data, nil + } + return nil, fmt.Errorf(`unexpected type %T from directive, should be *github.com/photoview/photoview/api/graphql/models.Album`, tmp) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*models.Album) + fc.Result = res + return ec.marshalOAlbum2ᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐAlbum(ctx, field.Selections, res) +} + func (ec *executionContext) _Mutation_setPeriodicScanInterval(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { @@ -8008,6 +8206,10 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) out.Values[i] = ec._Mutation_createUser(ctx, field) case "deleteUser": out.Values[i] = ec._Mutation_deleteUser(ctx, field) + case "userAddRootPath": + out.Values[i] = ec._Mutation_userAddRootPath(ctx, field) + case "userRemoveRootAlbum": + out.Values[i] = ec._Mutation_userRemoveRootAlbum(ctx, field) case "setPeriodicScanInterval": out.Values[i] = ec._Mutation_setPeriodicScanInterval(ctx, field) if out.Values[i] == graphql.Null { diff --git a/api/graphql/resolvers/user.go b/api/graphql/resolvers/user.go index 65029888..ceb378eb 100644 --- a/api/graphql/resolvers/user.go +++ b/api/graphql/resolvers/user.go @@ -217,3 +217,39 @@ func (r *mutationResolver) DeleteUser(ctx context.Context, id int) (*models.User return &user, nil } + +func (r *mutationResolver) UserAddRootPath(ctx context.Context, id int, rootPath string) (*models.Album, error) { + + var user models.User + if err := r.Database.First(&user, id).Error; err != nil { + return nil, err + } + + // TODO: Check if path exists and that user does not already own rootPath, directly or indirectly + + newAlbum, err := scanner.NewRootAlbum(r.Database, rootPath, &user) + if err != nil { + return nil, err + } + + return newAlbum, nil +} + +func (r *mutationResolver) UserRemoveRootAlbum(ctx context.Context, userID int, albumID int) (*models.Album, error) { + + var album models.Album + if err := r.Database.First(&album, albumID).Error; err != nil { + return nil, err + } + + result := r.Database.Exec("DELETE FROM user_albums WHERE album_id = ? AND user_id = ?", albumID, userID) + if result.Error != nil { + return nil, result.Error + } + + if result.RowsAffected == 0 { + return nil, errors.New("No relation deleted") + } + + return &album, nil +} diff --git a/api/graphql/schema.graphql b/api/graphql/schema.graphql index f4dcf526..c606cd64 100644 --- a/api/graphql/schema.graphql +++ b/api/graphql/schema.graphql @@ -95,6 +95,10 @@ type Mutation { ): User @isAdmin deleteUser(id: ID!): User @isAdmin + "Add a root path from where to look for media for the given user" + userAddRootPath(id: ID!, rootPath: String!): Album @isAdmin + userRemoveRootAlbum(userId: ID!, albumId: ID!): Album @isAdmin + """ Set how often, in seconds, the server should automatically scan for new media, a value of 0 will disable periodic scans diff --git a/ui/src/Pages/SettingsPage/Users/EditUserRow.js b/ui/src/Pages/SettingsPage/Users/EditUserRow.js index ac21e939..7865f629 100644 --- a/ui/src/Pages/SettingsPage/Users/EditUserRow.js +++ b/ui/src/Pages/SettingsPage/Users/EditUserRow.js @@ -1,84 +1,8 @@ import React from 'react' -import PropTypes from 'prop-types' -import styled from 'styled-components' -import { Button, Checkbox, Icon, Input, Table } from 'semantic-ui-react' +import { Button, Checkbox, Input, Table } from 'semantic-ui-react' +import { EditRootPaths } from './EditUserRowRootPaths' import { UserRowProps } from './UserRow' -const RootPathListItem = styled.li` - display: flex; - justify-content: space-between; - align-items: center; -` - -const EditRootPath = ({ filePath, removePath }) => ( - - {filePath} - - -) - -EditRootPath.propTypes = { - filePath: PropTypes.string.isRequired, - removePath: PropTypes.func.isRequired, -} - -const NewRootPathInput = styled(Input)` - width: 100%; - margin-top: 24px; -` - -const EditNewRootPath = ({ state, updateInput }) => ( -
  • - updateInput(e, 'rootPath')} - action={{ - positive: true, - icon: 'add', - content: 'Add', - }} - /> -
  • -) - -EditNewRootPath.propTypes = { - state: PropTypes.object.isRequired, - updateInput: PropTypes.func.isRequired, -} - -const RootPathList = styled.ul` - margin: 0; - padding: 0; - list-style: none; -` - -const EditRootPaths = ({ user, state, updateInput }) => { - const editRows = user.rootAlbums.map(album => ( - {}} - /> - )) - - return ( - - {editRows} - - - ) -} - -EditRootPaths.propTypes = { - updateInput: PropTypes.func.isRequired, - user: PropTypes.object.isRequired, - state: PropTypes.object.isRequired, -} - const EditUserRow = ({ user, state, @@ -104,17 +28,17 @@ const EditUserRow = ({ /> - + { - setState({ + setState(state => ({ ...state, admin: data.checked, - }) + })) }} /> @@ -123,9 +47,9 @@ const EditUserRow = ({ + + ) +} + +EditRootPath.propTypes = { + album: PropTypes.object.isRequired, + user: PropTypes.object.isRequired, +} + +const NewRootPathInput = styled(Input)` + width: 100%; + margin-top: 24px; +` + +const EditNewRootPath = ({ userID }) => { + const [value, setValue] = useState('') + const [addRootPath, { loading }] = useMutation(userAddRootPathMutation, { + refetchQueries: [ + { + query: USERS_QUERY, + }, + ], + }) + + return ( +
  • + setValue(e.target.value)} + disabled={loading} + action={{ + positive: true, + icon: 'add', + content: 'Add', + onClick: () => { + addRootPath({ + variables: { + id: userID, + rootPath: value, + }, + }) + }, + }} + /> +
  • + ) +} + +EditNewRootPath.propTypes = { + userID: PropTypes.string.isRequired, +} + +const RootPathList = styled.ul` + margin: 0; + padding: 0; + list-style: none; +` + +export const EditRootPaths = ({ user }) => { + const editRows = user.rootAlbums.map(album => ( + + )) + + return ( + + {editRows} + + + ) +} + +EditRootPaths.propTypes = { + user: PropTypes.object.isRequired, +} diff --git a/ui/src/Pages/SettingsPage/Users/UserRow.js b/ui/src/Pages/SettingsPage/Users/UserRow.js index 7a19b14d..427defe4 100644 --- a/ui/src/Pages/SettingsPage/Users/UserRow.js +++ b/ui/src/Pages/SettingsPage/Users/UserRow.js @@ -46,6 +46,7 @@ const UserRow = ({ user, refetchUsers }) => { const [state, setState] = useState({ ...user, editing: false, + newRootPath: '', }) const [showConfirmDelete, setConfirmDelete] = useState(false) diff --git a/ui/src/Pages/SettingsPage/Users/UsersTable.js b/ui/src/Pages/SettingsPage/Users/UsersTable.js index fa628d99..279446b5 100644 --- a/ui/src/Pages/SettingsPage/Users/UsersTable.js +++ b/ui/src/Pages/SettingsPage/Users/UsersTable.js @@ -6,7 +6,7 @@ import UserRow from './UserRow' import AddUserRow from './AddUserRow' import { SectionTitle } from '../SettingsPage' -const USERS_QUERY = gql` +export const USERS_QUERY = gql` query settingsUsersQuery { user { id diff --git a/ui/src/Pages/SettingsPage/Users/ViewUserRow.js b/ui/src/Pages/SettingsPage/Users/ViewUserRow.js index d90432ac..17053fe7 100644 --- a/ui/src/Pages/SettingsPage/Users/ViewUserRow.js +++ b/ui/src/Pages/SettingsPage/Users/ViewUserRow.js @@ -12,7 +12,7 @@ const PathList = styled.ul` const ViewUserRow = ({ user, - state, + // state, setState, scanUser, deleteUser, @@ -41,7 +41,7 @@ const ViewUserRow = ({