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/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 071d41cc..9d7744ae 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 { @@ -115,20 +116,21 @@ 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 + UserAddRootPath func(childComplexity int, id int, rootPath string) int + UserRemoveRootAlbum func(childComplexity int, userID int, albumID int) int } Notification struct { @@ -192,10 +194,11 @@ type ComplexityRoot struct { } User struct { - Admin func(childComplexity int) int - ID func(childComplexity int) int - RootPath 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 { @@ -216,6 +219,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) @@ -225,12 +230,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,9 +245,11 @@ 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) + 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) } @@ -266,6 +274,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 @@ -622,7 +634,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 +696,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 +773,31 @@ 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 "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 { @@ -1100,6 +1124,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,12 +1138,12 @@ 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 { + case "User.rootAlbums": + if e.complexity.User.RootAlbums == nil { break } - return e.complexity.User.RootPath(childComplexity), true + return e.complexity.User.RootAlbums(childComplexity), true case "User.username": if e.complexity.User.Username == nil { @@ -1332,13 +1363,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,18 +1390,20 @@ 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 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 @@ -1454,8 +1480,11 @@ type User { id: ID! username: String! #albums: [Album] - "Local filepath for the user's photos" - rootPath: String! @isAdmin + # rootPath: String! @isAdmin + "All albums owned by this user" + albums: [Album!]! @isAdmin + "Top level albums owned by this user" + rootAlbums: [Album!]! @isAdmin admin: Boolean! #shareTokens: [ShareToken] } @@ -1653,33 +1682,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 +1814,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 +1947,71 @@ 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 +} + +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 } @@ -2437,14 +2463,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) @@ -2456,9 +2482,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) { @@ -3011,14 +3037,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 +3808,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 +4165,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 +4224,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 { @@ -4332,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 { @@ -6031,7 +6133,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)) @@ -6042,15 +6144,15 @@ func (ec *executionContext) _User_rootPath(ctx context.Context, field graphql.Co 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.RootPath, nil + return ec.resolvers.User().Albums(rctx, obj) } directive1 := func(ctx context.Context) (interface{}, error) { if ec.directives.IsAdmin == nil { @@ -6066,10 +6168,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 +6183,64 @@ 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_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) { @@ -7647,10 +7804,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 { @@ -7809,10 +7975,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 +8178,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": @@ -8036,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 { @@ -8523,22 +8697,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++ - } - case "rootPath": - out.Values[i] = ec._User_rootPath(ctx, field, obj) - if out.Values[i] == graphql.Null { - invalids++ + atomic.AddUint32(&invalids, 1) } + case "albums": + 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)) diff --git a/api/graphql/models/album.go b/api/graphql/models/album.go index 62b6b8a6..4f290573 100644 --- a/api/graphql/models/album.go +++ b/api/graphql/models/album.go @@ -11,11 +11,12 @@ type Album struct { Model 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"` + ParentAlbum *Album `gorm:"constraint:OnDelete:SET NULL;"` + // 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 { @@ -27,3 +28,17 @@ func (a *Album) BeforeSave(tx *gorm.DB) (err error) { a.PathHash = hex.EncodeToString(hash[:]) return nil } + +func (a *Album) GetChildren(db *gorm.DB) (children []*Album, err error) { + err = db.Raw(` + WITH recursive sub_albums AS ( + SELECT * FROM albums AS root WHERE id = ? + UNION ALL + SELECT child.* FROM albums AS child JOIN sub_albums ON child.parent_album_id = sub_albums.id + ) + + SELECT * FROM sub_albums + `, a.ID).Find(&children).Error + + return children, err +} diff --git a/api/graphql/models/base.go b/api/graphql/models/base.go index b44841df..2c56b385 100644 --- a/api/graphql/models/base.go +++ b/api/graphql/models/base.go @@ -2,8 +2,6 @@ package models import ( "time" - - "gorm.io/gorm" ) type Model struct { @@ -14,5 +12,4 @@ type Model struct { type ModelTimestamps struct { CreatedAt time.Time UpdatedAt time.Time - DeletedAt gorm.DeletedAt `gorm:"index"` } diff --git a/api/graphql/models/media.go b/api/graphql/models/media.go index 2fe9eb28..36d813bb 100644 --- a/api/graphql/models/media.go +++ b/api/graphql/models/media.go @@ -13,20 +13,20 @@ 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 `gorm:"constraint:OnDelete:CASCADE;"` + ExifID *int + Exif *MediaEXIF `gorm:"constraint:OnDelete:SET NULL;"` + MediaURL []MediaURL `gorm:"constraint:OnDelete:CASCADE;"` + 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 + VideoMetadata *VideoMetadata `gorm:"constraint:OnDelete:SET NULL;"` SideCarPath *string SideCarHash *string @@ -52,6 +52,18 @@ func (m *Media) BeforeSave(tx *gorm.DB) error { return nil } +func (m *Media) BeforeDelete(tx *gorm.DB) error { + if err := tx.Model(m).Association("Exif").Clear(); err != nil { + return err + } + + if err := tx.Model(m).Association("MediaURL").Clear(); err != nil { + return err + } + + return nil +} + type MediaPurpose string const ( @@ -64,8 +76,8 @@ const ( type MediaURL struct { Model - MediaID int `gorm:"not null"` - Media Media + MediaID int `gorm:"not null"` + Media Media `gorm:"constraint:OnDelete:CASCADE;"` MediaName string `gorm:"not null"` Width int `gorm:"not null"` Height int `gorm:"not null"` diff --git a/api/graphql/models/share_token.go b/api/graphql/models/share_token.go index 7f374c67..abb4ec11 100644 --- a/api/graphql/models/share_token.go +++ b/api/graphql/models/share_token.go @@ -8,13 +8,13 @@ type ShareToken struct { Model Value string `gorm:"not null"` OwnerID int `gorm:"not null"` - Owner User + Owner User `gorm:"constraint:OnDelete:CASCADE;"` Expire *time.Time Password *string AlbumID *int - Album *Album + Album *Album `gorm:"constraint:OnDelete:CASCADE;"` MediaID *int - Media *Media + Media *Media `gorm:"constraint:OnDelete:CASCADE;"` } func (share *ShareToken) Token() string { diff --git a/api/graphql/models/user.go b/api/graphql/models/user.go index 7b2f1432..2d4505d8 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 { @@ -192,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..c19998e9 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 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 d7d00024..21482d27 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) { @@ -17,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) @@ -44,7 +54,7 @@ func (r *queryResolver) Media(ctx context.Context, id int) (*models.Media, error err := r.Database. Joins("Album"). Where("media.id = ?", id). - Where("Album.owner_id = ?", user.ID). + Where("EXISTS (SELECT * FROM user_albums WHERE user_albums.album_id = Album.id AND user_albums.user_id = ?)", user.ID). Where("media.id IN (?)", r.Database.Model(&models.MediaURL{}).Select("media_id").Where("media_urls.media_id = media.id")). First(&media).Error @@ -182,20 +192,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/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/share_token.go b/api/graphql/resolvers/share_token.go index f9d59af3..16586532 100644 --- a/api/graphql/resolvers/share_token.go +++ b/api/graphql/resolvers/share_token.go @@ -100,7 +100,12 @@ func (r *mutationResolver) ShareAlbum(ctx context.Context, albumID int, expire * } var count int64 - if err := r.Database.Model(&models.Album{}).Where("owner_id = ?", user.ID).Count(&count).Error; err != nil { + err := r.Database. + Model(&models.Album{}). + Where("EXISTS (SELECT * FROM user_albums WHERE user_albums.album_id = albums.id AND user_albums.user_id = ?)", user.ID). + Count(&count).Error + + if err != nil { return nil, errors.Wrap(err, "failed to validate album owner with database") } @@ -142,7 +147,12 @@ func (r *mutationResolver) ShareMedia(ctx context.Context, mediaID int, expire * var media models.Media - if err := r.Database.Joins("Album").Where("Album.owner_id = ?", user.ID).First(&media, mediaID).Error; err != nil { + err := r.Database.Joins("Album"). + Where("EXISTS (SELECT * FROM user_albums WHERE user_albums.album_id = Album.id AND user_albums.user_id = ?)", user.ID). + First(&media, mediaID). + Error + + if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, auth.ErrUnauthorized } else { @@ -150,17 +160,6 @@ func (r *mutationResolver) ShareMedia(ctx context.Context, mediaID int, expire * } } - var count int64 - - err := r.Database.Raw("SELECT owner_id FROM albums, media WHERE media.id = ? AND media.album_id = albums.id AND albums.owner_id = ?", mediaID, user.ID).Count(&count).Error - if err != nil { - return nil, errors.Wrap(err, "error validating owner of media with database") - } - - if count == 0 { - return nil, auth.ErrUnauthorized - } - hashedPassword, err := hashSharePassword(password) if err != nil { return nil, err diff --git a/api/graphql/resolvers/user.go b/api/graphql/resolvers/user.go index 50c0e986..67ae2008 100644 --- a/api/graphql/resolvers/user.go +++ b/api/graphql/resolvers/user.go @@ -2,14 +2,29 @@ package resolvers import ( "context" + "fmt" + "os" + "path" + "strconv" + "strings" + 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" "github.com/pkg/errors" "golang.org/x/crypto/bcrypt" "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 @@ -21,6 +36,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) @@ -61,38 +100,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) @@ -104,6 +111,8 @@ func (r *mutationResolver) InitialSetupWizard(ctx context.Context, username stri return nil, errors.New("not initial setup") } + rootPath = path.Clean(rootPath) + var token *models.AccessToken transactionError := r.Database.Transaction(func(tx *gorm.DB) error { @@ -111,7 +120,12 @@ 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 + } + + _, err = scanner.NewRootAlbum(tx, rootPath, user) if err != nil { return err } @@ -139,9 +153,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 +168,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 +189,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 } @@ -214,3 +224,124 @@ 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) { + + rootPath = path.Clean(rootPath) + + var user models.User + if err := r.Database.First(&user, id).Error; err != nil { + return nil, err + } + + if !models.ValidRootPath(rootPath) { + return nil, errors.New("invalid root path") + } + + upperPaths := make([]string, 1) + upperPath := rootPath + upperPaths[0] = upperPath + for { + + substrIndex := strings.LastIndex(upperPath, "/") + if substrIndex == -1 { + break + } + + if substrIndex == 0 { + upperPaths = append(upperPaths, "/") + break + } + + upperPath = upperPath[0:substrIndex] + upperPaths = append(upperPaths, upperPath) + } + + var upperAlbums []models.Album + if err := r.Database.Model(&user).Association("Albums").Find(&upperAlbums, "albums.path IN (?)", upperPaths); err != nil { + // if err := r.Database.Model(models.Album{}).Where("path IN (?)", upperPaths).Find(&upperAlbums).Error; err != nil { + return nil, err + } + + if len(upperAlbums) > 0 { + return nil, errors.New(fmt.Sprintf("user already owns a path containing this path: %s", upperAlbums[0].Path)) + } + + 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 + } + + var deletedAlbumIDs []int = nil + + err := r.Database.Transaction(func(tx *gorm.DB) error { + if err := tx.Raw("DELETE FROM user_albums WHERE user_id = ? AND album_id = ?", userID, albumID).Error; err != nil { + return err + } + + children, err := album.GetChildren(tx) + if err != nil { + return err + } + + childAlbumIDs := make([]int, len(children)) + for i, child := range children { + childAlbumIDs[i] = child.ID + } + + result := tx.Exec("DELETE FROM user_albums WHERE user_id = ? and album_id IN (?)", userID, childAlbumIDs) + if result.Error != nil { + return result.Error + } + + if result.RowsAffected == 0 { + return errors.New("No relation deleted") + } + + // Cleanup if no user owns the album anymore + var count int + if err := tx.Raw("SELECT COUNT(user_id) FROM user_albums WHERE album_id = ?", albumID).Scan(&count).Error; err != nil { + return err + } + + if count == 0 { + deletedAlbumIDs = append(childAlbumIDs, albumID) + childAlbumIDs = nil + + // Delete albums from database + if err := tx.Delete(&models.Album{}, "id IN (?)", deletedAlbumIDs).Error; err != nil { + deletedAlbumIDs = nil + return err + } + } + + return nil + }) + + if err != nil { + return nil, err + } + + if deletedAlbumIDs != nil { + // Delete albums from cache + for _, id := range deletedAlbumIDs { + cacheAlbumPath := path.Join(scanner.PhotoCache(), strconv.Itoa(id)) + + if err := os.RemoveAll(cacheAlbumPath); err != nil { + return nil, err + } + } + } + + return &album, nil +} diff --git a/api/graphql/schema.graphql b/api/graphql/schema.graphql index 3c8e95cf..c606cd64 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,18 +85,20 @@ 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 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 @@ -180,8 +175,11 @@ type User { id: ID! username: String! #albums: [Album] - "Local filepath for the user's photos" - rootPath: String! @isAdmin + # rootPath: String! @isAdmin + "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/api/routes/authenticate_media.go b/api/routes/authenticate_media.go index 8e42f413..80d1ed3d 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 { @@ -55,20 +60,21 @@ func authenticateMedia(media *models.Media, db *gorm.DB, r *http.Request) (succe if shareToken.AlbumID != nil && media.AlbumID != *shareToken.AlbumID { // Check child albums - result := db.Raw(` + var count int + err := db.Raw(` WITH recursive child_albums AS ( - SELECT * FROM album WHERE parent_album = ? + SELECT * FROM albums WHERE parent_album_id = ? UNION ALL - SELECT child.* FROM album child JOIN child_albums parent ON parent.album_id = child.parent_album + SELECT child.* FROM albums child JOIN child_albums parent ON parent.id = child.parent_album_id ) - SELECT * FROM child_albums WHERE album_id = ? - `, *shareToken.AlbumID, media.AlbumID) + SELECT COUNT(id) FROM child_albums WHERE id = ? + `, *shareToken.AlbumID, media.AlbumID).Find(&count).Error - if err := result.Error; err != nil { + if err != nil { return false, "internal server error", http.StatusInternalServerError, err } - if result.RowsAffected == 0 { + if count == 0 { return false, "unauthorized", http.StatusForbidden, nil } } 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..1a328573 100644 --- a/api/scanner/scanner_album.go +++ b/api/scanner/scanner_album.go @@ -13,6 +13,40 @@ import ( "gorm.io/gorm" ) +func NewRootAlbum(db *gorm.DB, rootPath string, owner *models.User) (*models.Album, error) { + + owners := []models.User{ + *owner, + } + + var matchedAlbums []models.Album + if err := db.Where("path_hash = MD5(?)", rootPath).Find(&matchedAlbums).Error; err != nil { + return nil, err + } + + if len(matchedAlbums) > 0 { + album := matchedAlbums[0] + + if err := db.Model(&owner).Association("Albums").Append(&album); err != nil { + return nil, errors.Wrap(err, "failed to add owner to already existing album") + } + + return &album, nil + } else { + 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 1b1a322e..eb4ba153 100644 --- a/api/scanner/scanner_user.go +++ b/api/scanner/scanner_user.go @@ -17,71 +17,112 @@ 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)} - } + 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.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() - 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, + parent: nil, + }) + } + } userAlbums := make([]*models.Album, 0) - albumErrors := make([]error, 0) - // newPhotos := make([]*models.Photo, 0) for scanQueue.Front() != nil { albumInfo := scanQueue.Front().Value.(scanInfo) scanQueue.Remove(scanQueue.Front()) albumPath := albumInfo.path - albumParentID := albumInfo.parentID + albumParent := albumInfo.parent // 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 } // 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 }) if transErr != nil { - albumErrors = append(albumErrors, errors.Wrap(transErr, "begin database transaction")) + scanErrors = append(scanErrors, errors.Wrap(transErr, "begin database transaction")) continue } @@ -96,17 +137,17 @@ 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, }) } } } 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 { 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..881eb3a4 --- /dev/null +++ b/ui/src/Pages/SettingsPage/Users/EditUserRow.js @@ -0,0 +1,81 @@ +import React from 'react' +import { Button, Checkbox, Input, Table } from 'semantic-ui-react' +import { EditRootPaths } from './EditUserRowRootPaths' +import { UserRowProps } from './UserRow' + +const EditUserRow = ({ + user, + state, + setState, + updateUser, + updateUserLoading, +}) => { + function updateInput(event, key) { + setState(state => ({ + ...state, + [key]: event.target.value, + })) + } + + return ( + + + updateInput(e, 'username')} + /> + + + + + + { + setState(state => ({ + ...state, + admin: data.checked, + })) + }} + /> + + + + + + + + + ) +} + +EditUserRow.propTypes = UserRowProps + +export default EditUserRow diff --git a/ui/src/Pages/SettingsPage/Users/EditUserRowRootPaths.js b/ui/src/Pages/SettingsPage/Users/EditUserRowRootPaths.js new file mode 100644 index 00000000..f5f0ff87 --- /dev/null +++ b/ui/src/Pages/SettingsPage/Users/EditUserRowRootPaths.js @@ -0,0 +1,135 @@ +import PropTypes from 'prop-types' +import React, { useState } from 'react' +import { gql, useMutation } from '@apollo/client' +import { Button, Icon, Input } from 'semantic-ui-react' +import styled from 'styled-components' +import { USERS_QUERY } from './UsersTable' + +const userAddRootPathMutation = gql` + mutation userAddRootPath($id: ID!, $rootPath: String!) { + userAddRootPath(id: $id, rootPath: $rootPath) { + id + } + } +` + +const userRemoveAlbumPathMutation = gql` + mutation userRemoveAlbumPathMutation($userId: ID!, $albumId: ID!) { + userRemoveRootAlbum(userId: $userId, albumId: $albumId) { + id + } + } +` + +const RootPathListItem = styled.li` + display: flex; + justify-content: space-between; + align-items: center; +` + +const EditRootPath = ({ album, user }) => { + const [removeAlbumPath, { loading }] = useMutation( + userRemoveAlbumPathMutation, + { + refetchQueries: [ + { + query: USERS_QUERY, + }, + ], + } + ) + + return ( + + {album.filePath} + + + ) +} + +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: () => { + setValue('') + 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/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..05728b9f --- /dev/null +++ b/ui/src/Pages/SettingsPage/Users/UserRow.js @@ -0,0 +1,111 @@ +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, $admin: Boolean) { + updateUser(id: $id, username: $username, admin: $admin) { + id + username + 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, + newRootPath: '', + }) + + 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 92% rename from ui/src/Pages/SettingsPage/UsersTable.js rename to ui/src/Pages/SettingsPage/Users/UsersTable.js index ea88ecea..279446b5 100644 --- a/ui/src/Pages/SettingsPage/UsersTable.js +++ b/ui/src/Pages/SettingsPage/Users/UsersTable.js @@ -4,15 +4,19 @@ 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` +export const USERS_QUERY = gql` query settingsUsersQuery { user { id username - rootPath + # rootPath admin + 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..17053fe7 --- /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 diff --git a/ui/src/Pages/SharePage/SharePage.js b/ui/src/Pages/SharePage/SharePage.js index b8c49ef4..e303d9f8 100644 --- a/ui/src/Pages/SharePage/SharePage.js +++ b/ui/src/Pages/SharePage/SharePage.js @@ -61,6 +61,8 @@ export const SHARE_TOKEN_QUERY = gql` } highRes { url + width + height } videoWeb { url diff --git a/ui/src/components/sidebar/AlbumSidebar.js b/ui/src/components/sidebar/AlbumSidebar.js index f703c18b..0a8ba1b4 100644 --- a/ui/src/components/sidebar/AlbumSidebar.js +++ b/ui/src/components/sidebar/AlbumSidebar.js @@ -32,7 +32,7 @@ const AlbumSidebar = ({ albumId }) => { } AlbumSidebar.propTypes = { - albumId: PropTypes.number.isRequired, + albumId: PropTypes.string.isRequired, } export default AlbumSidebar