Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow visitors to create posts with tags #1221

Merged
merged 9 commits into from
Dec 17, 2024
Merged
4 changes: 3 additions & 1 deletion .test.env
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -37,4 +39,4 @@ EMAIL_MAILGUN_API=mys3cr3tk3y
EMAIL_MAILGUN_DOMAIN=mydomain.com

USER_LIST_ENABLED=true
USER_LIST_APIKEY=abcdefg
USER_LIST_APIKEY=abcdefg
34 changes: 33 additions & 1 deletion app/actions/post.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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
Expand All @@ -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 {
Expand Down
10 changes: 10 additions & 0 deletions app/handlers/apiv1/post.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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))

Expand Down
180 changes: 180 additions & 0 deletions app/handlers/apiv1/post_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -63,6 +64,185 @@ func TestCreatePostHandler_WithoutTitle(t *testing.T) {
Expect(code).Equals(http.StatusBadRequest)
}

func TestCreatePostHandler_WithInexistentTag(t *testing.T) {
MercierMateo marked this conversation as resolved.
Show resolved Hide resolved
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)

Expand Down
13 changes: 7 additions & 6 deletions app/pkg/env/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah this looks good. I was contemplating if we should have a nested AppFeatures struct, and move this into there so that we could add other things in future, but I think we probably don't need to do that...

Paddle struct {
IsSandbox bool `env:"PADDLE_SANDBOX,default=false"`
VendorID string `env:"PADDLE_VENDOR_ID"`
Expand Down
Loading