diff --git a/.test.env b/.test.env index f1e0a7123..f5d61a25f 100644 --- a/.test.env +++ b/.test.env @@ -8,6 +8,8 @@ JWT_SECRET=SOME_RANDOM_TOKEN_JUST_FOR_TESTING METRICS_ENABLED=true +POST_CREATION_WITH_TAGS_ENABLED=true + BLOB_STORAGE=s3 BLOB_STORAGE_S3_ENDPOINT_URL=http://localhost:9000 BLOB_STORAGE_S3_REGION=us-east-1 @@ -37,4 +39,4 @@ EMAIL_MAILGUN_API=mys3cr3tk3y EMAIL_MAILGUN_DOMAIN=mydomain.com USER_LIST_ENABLED=true -USER_LIST_APIKEY=abcdefg \ No newline at end of file +USER_LIST_APIKEY=abcdefg diff --git a/app/actions/post.go b/app/actions/post.go index 89314a8dd..061b0d27f 100644 --- a/app/actions/post.go +++ b/app/actions/post.go @@ -9,6 +9,7 @@ import ( "github.com/getfider/fider/app/models/enum" "github.com/getfider/fider/app/models/query" "github.com/getfider/fider/app/pkg/bus" + "github.com/getfider/fider/app/pkg/env" "github.com/getfider/fider/app/pkg/i18n" "github.com/gosimple/slug" @@ -21,12 +22,41 @@ import ( type CreateNewPost struct { Title string `json:"title"` Description string `json:"description"` + TagSlugs []string `json:"tags"` Attachments []*dto.ImageUpload `json:"attachments"` + + Tags []*entity.Tag +} + +// OnPreExecute prefetches Tags for later use +func (input *CreateNewPost) OnPreExecute(ctx context.Context) error { + if env.Config.PostCreationWithTagsEnabled { + input.Tags = make([]*entity.Tag, 0, len(input.TagSlugs)) + for _, slug := range input.TagSlugs { + getTag := &query.GetTagBySlug{Slug: slug} + if err := bus.Dispatch(ctx, getTag); err != nil { + break + } + + input.Tags = append(input.Tags, getTag.Result) + } + } + + return nil } // IsAuthorized returns true if current user is authorized to perform this action func (action *CreateNewPost) IsAuthorized(ctx context.Context, user *entity.User) bool { - return user != nil + if user == nil { + return false + } else if env.Config.PostCreationWithTagsEnabled && !user.IsCollaborator() { + for _, tag := range action.Tags { + if !tag.IsPublic { + return false + } + } + } + return true } // Validate if current model is valid @@ -39,6 +69,8 @@ func (action *CreateNewPost) Validate(ctx context.Context, user *entity.User) *v result.AddFieldFailure("title", i18n.T(ctx, "validation.custom.descriptivetitle")) } else if len(action.Title) > 100 { result.AddFieldFailure("title", propertyMaxStringLen(ctx, "title", 100)) + } else if env.Config.PostCreationWithTagsEnabled && len(action.TagSlugs) != len(action.Tags) { + result.AddFieldFailure("tags", propertyIsInvalid(ctx, "tags")) } else { err := bus.Dispatch(ctx, &query.GetPostBySlug{Slug: slug.Make(action.Title)}) if err != nil && errors.Cause(err) != app.ErrNotFound { diff --git a/app/handlers/apiv1/post.go b/app/handlers/apiv1/post.go index 3718315ff..19f2631cd 100644 --- a/app/handlers/apiv1/post.go +++ b/app/handlers/apiv1/post.go @@ -8,6 +8,7 @@ import ( "github.com/getfider/fider/app/models/enum" "github.com/getfider/fider/app/models/query" "github.com/getfider/fider/app/pkg/bus" + "github.com/getfider/fider/app/pkg/env" "github.com/getfider/fider/app/pkg/web" "github.com/getfider/fider/app/tasks" ) @@ -59,6 +60,15 @@ func CreatePost() web.HandlerFunc { if err = bus.Dispatch(c, setAttachments, addVote); err != nil { return c.Failure(err) } + + if env.Config.PostCreationWithTagsEnabled { + for _, tag := range action.Tags { + assignTag := &cmd.AssignTag{Tag: tag, Post: newPost.Result} + if err := bus.Dispatch(c, assignTag); err != nil { + return c.Failure(err) + } + } + } c.Enqueue(tasks.NotifyAboutNewPost(newPost.Result)) diff --git a/app/handlers/apiv1/post_test.go b/app/handlers/apiv1/post_test.go index 1bb77621e..27839eb67 100644 --- a/app/handlers/apiv1/post_test.go +++ b/app/handlers/apiv1/post_test.go @@ -17,6 +17,7 @@ import ( "github.com/getfider/fider/app/handlers/apiv1" . "github.com/getfider/fider/app/pkg/assert" "github.com/getfider/fider/app/pkg/bus" + "github.com/getfider/fider/app/pkg/env" "github.com/getfider/fider/app/pkg/mock" ) @@ -63,6 +64,185 @@ func TestCreatePostHandler_WithoutTitle(t *testing.T) { Expect(code).Equals(http.StatusBadRequest) } +func TestCreatePostHandler_WithNonExistentTag(t *testing.T) { + if env.Config.PostCreationWithTagsEnabled { + RegisterT(t) + + bus.AddHandler(func(ctx context.Context, q *query.GetTagBySlug) error { + return app.ErrNotFound + }) + bus.AddHandler(func(ctx context.Context, q *query.GetPostBySlug) error { + return app.ErrNotFound + }) + + code, _ := mock.NewServer(). + OnTenant(mock.DemoTenant). + AsUser(mock.JonSnow). + ExecutePost(apiv1.CreatePost(), `{ "title": "My newest post :)", "tags": ["inexistent_tag"]}`) + + Expect(code).Equals(http.StatusBadRequest) + } +} + +func TestCreatePostHandler_WithPrivateTagAsVisitor(t *testing.T) { + if env.Config.PostCreationWithTagsEnabled { + RegisterT(t) + + privateTag := &entity.Tag{ + ID: 1, + Name: "private_tag", + Slug: "private_tag", + Color: "blue", + IsPublic: false, + } + bus.AddHandler(func(ctx context.Context, q *query.GetTagBySlug) error { + if q.Slug == "private_tag" { + q.Result = privateTag + return nil + } + return app.ErrNotFound + }) + + bus.AddHandler(func(ctx context.Context, q *query.GetPostBySlug) error { + return app.ErrNotFound + }) + + code, _ := mock.NewServer(). + OnTenant(mock.DemoTenant). + AsUser(mock.AryaStark). + ExecutePost(apiv1.CreatePost(), `{ "title": "My newest post :)", "tags": ["private_tag"]}`) + + Expect(code).Equals(http.StatusForbidden) + } +} + +func TestCreatePostHandler_WithPublicTagAsVisitor(t *testing.T) { + if env.Config.PostCreationWithTagsEnabled { + RegisterT(t) + + var newPost *cmd.AddNewPost + bus.AddHandler(func(ctx context.Context, c *cmd.AddNewPost) error { + newPost = c + c.Result = &entity.Post{ + ID: 1, + Title: c.Title, + Description: c.Description, + } + return nil + }) + + publicTag := &entity.Tag{ + ID: 1, + Name: "public_tag", + Slug: "public_tag", + Color: "red", + IsPublic: true, + } + bus.AddHandler(func(ctx context.Context, q *query.GetTagBySlug) error { + if q.Slug == "public_tag" { + q.Result = publicTag + return nil + } + return app.ErrNotFound + }) + + var tagAssignment *cmd.AssignTag + bus.AddHandler(func(ctx context.Context, c *cmd.AssignTag) error { + tagAssignment = c + return nil + }) + + bus.AddHandler(func(ctx context.Context, q *query.GetPostBySlug) error { + return app.ErrNotFound + }) + + bus.AddHandler(func(ctx context.Context, c *cmd.SetAttachments) error { return nil }) + bus.AddHandler(func(ctx context.Context, c *cmd.AddVote) error { return nil }) + bus.AddHandler(func(ctx context.Context, c *cmd.UploadImages) error { return nil }) + + code, _ := mock.NewServer(). + OnTenant(mock.DemoTenant). + AsUser(mock.AryaStark). + ExecutePost(apiv1.CreatePost(), `{ "title": "My newest post :)", "tags": ["public_tag"]}`) + + Expect(code).Equals(http.StatusOK) + Expect(tagAssignment.Tag).Equals(publicTag) + Expect(tagAssignment.Post).Equals(newPost.Result) + } +} + +func TestCreatePostHandler_WithPublicTagAndPrivateTagAsCollaborator(t *testing.T) { + if env.Config.PostCreationWithTagsEnabled { + RegisterT(t) + + var newPost *cmd.AddNewPost + bus.AddHandler(func(ctx context.Context, c *cmd.AddNewPost) error { + newPost = c + c.Result = &entity.Post{ + ID: 1, + Title: c.Title, + Description: c.Description, + } + return nil + }) + + publicTag := &entity.Tag{ + ID: 1, + Name: "public_tag", + Slug: "public_tag", + Color: "red", + IsPublic: true, + } + privateTag := &entity.Tag{ + ID: 1, + Name: "private_tag", + Slug: "private_tag", + Color: "blue", + IsPublic: false, + } + bus.AddHandler(func(ctx context.Context, q *query.GetTagBySlug) error { + if q.Slug == "public_tag" { + q.Result = publicTag + return nil + } + if q.Slug == "private_tag" { + q.Result = privateTag + return nil + } + return app.ErrNotFound + }) + + tagAssignments := make([]*cmd.AssignTag, 2) + bus.AddHandler(func(ctx context.Context, c *cmd.AssignTag) error { + if c.Tag.Slug == "public_tag" { + tagAssignments[0] = c + } else if c.Tag.Slug == "private_tag" { + tagAssignments[1] = c + } + return nil + }) + + bus.AddHandler(func(ctx context.Context, q *query.GetPostBySlug) error { + return app.ErrNotFound + }) + + bus.AddHandler(func(ctx context.Context, c *cmd.SetAttachments) error { return nil }) + bus.AddHandler(func(ctx context.Context, c *cmd.AddVote) error { return nil }) + bus.AddHandler(func(ctx context.Context, c *cmd.UploadImages) error { return nil }) + + code, _ := mock.NewServer(). + OnTenant(mock.DemoTenant). + AsUser(mock.JonSnow). + ExecutePost(apiv1.CreatePost(), `{ "title": "My newest post :)", "tags": ["public_tag", "private_tag"]}`) + + Expect(code).Equals(http.StatusOK) + Expect(tagAssignments[0].Tag).Equals(publicTag) + Expect(tagAssignments[1].Tag).Equals(privateTag) + Expect(tagAssignments[0].Post).Equals(newPost.Result) + Expect(tagAssignments[1].Post).Equals(newPost.Result) + } +} + func TestGetPostHandler(t *testing.T) { RegisterT(t) diff --git a/app/pkg/env/env.go b/app/pkg/env/env.go index 027036338..cb1327846 100644 --- a/app/pkg/env/env.go +++ b/app/pkg/env/env.go @@ -42,12 +42,13 @@ type config struct { WriteTimeout time.Duration `env:"HTTP_WRITE_TIMEOUT,default=10s,strict"` IdleTimeout time.Duration `env:"HTTP_IDLE_TIMEOUT,default=120s,strict"` } - Port string `env:"PORT,default=3000"` - HostMode string `env:"HOST_MODE,default=single"` - HostDomain string `env:"HOST_DOMAIN"` - BaseURL string `env:"BASE_URL"` - Locale string `env:"LOCALE,default=en"` - JWTSecret string `env:"JWT_SECRET,required"` + Port string `env:"PORT,default=3000"` + HostMode string `env:"HOST_MODE,default=single"` + HostDomain string `env:"HOST_DOMAIN"` + BaseURL string `env:"BASE_URL"` + Locale string `env:"LOCALE,default=en"` + JWTSecret string `env:"JWT_SECRET,required"` + PostCreationWithTagsEnabled bool `env:"POST_CREATION_WITH_TAGS_ENABLED,default=false"` Paddle struct { IsSandbox bool `env:"PADDLE_SANDBOX,default=false"` VendorID string `env:"PADDLE_VENDOR_ID"`