diff --git a/integrations/api_comment_attachment_test.go b/integrations/api_comment_attachment_test.go new file mode 100644 index 0000000000000..ac163b7f67d45 --- /dev/null +++ b/integrations/api_comment_attachment_test.go @@ -0,0 +1,148 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package integrations + +import ( + "bytes" + "fmt" + "io" + "mime/multipart" + "net/http" + "testing" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/convert" + api "code.gitea.io/gitea/modules/structs" + + "github.com/stretchr/testify/assert" +) + +func TestAPIGetCommentAttachment(t *testing.T) { + defer prepareTestEnv(t)() + + comment := db.AssertExistsAndLoadBean(t, &models.Comment{ID: 2}).(*models.Comment) + assert.NoError(t, comment.LoadIssue()) + assert.NoError(t, comment.LoadAttachments()) + attachment := db.AssertExistsAndLoadBean(t, &models.Attachment{ID: comment.Attachments[0].ID}).(*models.Attachment) + repo := db.AssertExistsAndLoadBean(t, &models.Repository{ID: comment.Issue.RepoID}).(*models.Repository) + repoOwner := db.AssertExistsAndLoadBean(t, &models.User{ID: repo.OwnerID}).(*models.User) + + session := loginUser(t, repoOwner.Name) + token := getTokenForLoggedInUser(t, session) + req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/issues/comments/%d/assets/%d", repoOwner.Name, repo.Name, comment.ID, attachment.ID) + resp := session.MakeRequest(t, req, http.StatusOK) + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/issues/comments/%d/assets/%d?token=%s", repoOwner.Name, repo.Name, comment.ID, attachment.ID, token) + resp = session.MakeRequest(t, req, http.StatusOK) + + var apiAttachment api.Attachment + DecodeJSON(t, resp, &apiAttachment) + + expect := convert.ToAttachment(attachment) + assert.Equal(t, expect.ID, apiAttachment.ID) + assert.Equal(t, expect.Name, apiAttachment.Name) + assert.Equal(t, expect.UUID, apiAttachment.UUID) + assert.Equal(t, expect.Created.Unix(), apiAttachment.Created.Unix()) +} + +func TestAPIListCommentAttachments(t *testing.T) { + defer prepareTestEnv(t)() + + comment := db.AssertExistsAndLoadBean(t, &models.Comment{ID: 2}).(*models.Comment) + issue := db.AssertExistsAndLoadBean(t, &models.Issue{ID: comment.IssueID}).(*models.Issue) + repo := db.AssertExistsAndLoadBean(t, &models.Repository{ID: issue.RepoID}).(*models.Repository) + repoOwner := db.AssertExistsAndLoadBean(t, &models.User{ID: repo.OwnerID}).(*models.User) + + session := loginUser(t, repoOwner.Name) + req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/issues/comments/%d/assets", + repoOwner.Name, repo.Name, comment.ID) + resp := session.MakeRequest(t, req, http.StatusOK) + + var apiAttachments []*api.Attachment + DecodeJSON(t, resp, &apiAttachments) + expectedCount := db.GetCount(t, &models.Attachment{CommentID: comment.ID}) + assert.EqualValues(t, expectedCount, len(apiAttachments)) + db.AssertExistsAndLoadBean(t, &models.Attachment{ID: apiAttachments[0].ID, CommentID: comment.ID}) +} + +func TestAPICreateCommentAttachment(t *testing.T) { + defer prepareTestEnv(t)() + + comment := db.AssertExistsAndLoadBean(t, &models.Comment{ID: 2}).(*models.Comment) + issue := db.AssertExistsAndLoadBean(t, &models.Issue{ID: comment.IssueID}).(*models.Issue) + repo := db.AssertExistsAndLoadBean(t, &models.Repository{ID: issue.RepoID}).(*models.Repository) + repoOwner := db.AssertExistsAndLoadBean(t, &models.User{ID: repo.OwnerID}).(*models.User) + + session := loginUser(t, repoOwner.Name) + token := getTokenForLoggedInUser(t, session) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/comments/%d/assets?token=%s", + repoOwner.Name, repo.Name, comment.ID, token) + + filename := "image.png" + buff := generateImg() + body := &bytes.Buffer{} + + // Setup multi-part + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile("attachment", filename) + assert.NoError(t, err) + _, err = io.Copy(part, &buff) + assert.NoError(t, err) + err = writer.Close() + assert.NoError(t, err) + + req := NewRequestWithBody(t, "POST", urlStr, body) + req.Header.Add("Content-Type", writer.FormDataContentType()) + resp := session.MakeRequest(t, req, http.StatusCreated) + + apiAttachment := new(api.Attachment) + DecodeJSON(t, resp, &apiAttachment) + + db.AssertExistsAndLoadBean(t, &models.Attachment{ID: apiAttachment.ID, CommentID: comment.ID}) +} + +func TestAPIEditCommentAttachment(t *testing.T) { + defer prepareTestEnv(t)() + const newAttachmentName = "newAttachmentName" + + attachment := db.AssertExistsAndLoadBean(t, &models.Attachment{ID: 6}).(*models.Attachment) + comment := db.AssertExistsAndLoadBean(t, &models.Comment{ID: attachment.CommentID}).(*models.Comment) + issue := db.AssertExistsAndLoadBean(t, &models.Issue{ID: comment.IssueID}).(*models.Issue) + repo := db.AssertExistsAndLoadBean(t, &models.Repository{ID: issue.RepoID}).(*models.Repository) + repoOwner := db.AssertExistsAndLoadBean(t, &models.User{ID: repo.OwnerID}).(*models.User) + + session := loginUser(t, repoOwner.Name) + token := getTokenForLoggedInUser(t, session) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/comments/%d/assets/%d?token=%s", + repoOwner.Name, repo.Name, comment.ID, attachment.ID, token) + req := NewRequestWithValues(t, "PATCH", urlStr, map[string]string{ + "name": newAttachmentName, + }) + resp := session.MakeRequest(t, req, http.StatusCreated) + apiAttachment := new(api.Attachment) + DecodeJSON(t, resp, &apiAttachment) + + db.AssertExistsAndLoadBean(t, &models.Attachment{ID: apiAttachment.ID, CommentID: comment.ID, Name: apiAttachment.Name}) +} + +func TestAPIDeleteCommentAttachment(t *testing.T) { + defer prepareTestEnv(t)() + + attachment := db.AssertExistsAndLoadBean(t, &models.Attachment{ID: 6}).(*models.Attachment) + comment := db.AssertExistsAndLoadBean(t, &models.Comment{ID: attachment.CommentID}).(*models.Comment) + issue := db.AssertExistsAndLoadBean(t, &models.Issue{ID: comment.IssueID}).(*models.Issue) + repo := db.AssertExistsAndLoadBean(t, &models.Repository{ID: issue.RepoID}).(*models.Repository) + repoOwner := db.AssertExistsAndLoadBean(t, &models.User{ID: repo.OwnerID}).(*models.User) + + session := loginUser(t, repoOwner.Name) + token := getTokenForLoggedInUser(t, session) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/comments/%d/assets/%d?token=%s", + repoOwner.Name, repo.Name, comment.ID, attachment.ID, token) + + req := NewRequestf(t, "DELETE", urlStr) + session.MakeRequest(t, req, http.StatusNoContent) + + db.AssertNotExistsBean(t, &models.Attachment{ID: attachment.ID, CommentID: comment.ID}) +} diff --git a/integrations/api_issue_attachment_test.go b/integrations/api_issue_attachment_test.go new file mode 100644 index 0000000000000..c8d0d3cff37e5 --- /dev/null +++ b/integrations/api_issue_attachment_test.go @@ -0,0 +1,139 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package integrations + +import ( + "bytes" + "fmt" + "io" + "mime/multipart" + "net/http" + "testing" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/models/db" + api "code.gitea.io/gitea/modules/structs" + + "github.com/stretchr/testify/assert" +) + +func TestAPIGetIssueAttachment(t *testing.T) { + defer prepareTestEnv(t)() + + attachment := db.AssertExistsAndLoadBean(t, &models.Attachment{ID: 1}).(*models.Attachment) + repo := db.AssertExistsAndLoadBean(t, &models.Repository{ID: attachment.RepoID}).(*models.Repository) + issue := db.AssertExistsAndLoadBean(t, &models.Issue{RepoID: attachment.IssueID}).(*models.Issue) + repoOwner := db.AssertExistsAndLoadBean(t, &models.User{ID: repo.OwnerID}).(*models.User) + + session := loginUser(t, repoOwner.Name) + token := getTokenForLoggedInUser(t, session) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/assets/%d?token=%s", + repoOwner.Name, repo.Name, issue.Index, attachment.ID, token) + + req := NewRequest(t, "GET", urlStr) + resp := session.MakeRequest(t, req, http.StatusOK) + apiAttachment := new(api.Attachment) + DecodeJSON(t, resp, &apiAttachment) + + db.AssertExistsAndLoadBean(t, &models.Attachment{ID: apiAttachment.ID, IssueID: issue.ID}) +} + +func TestAPIListIssueAttachments(t *testing.T) { + defer prepareTestEnv(t)() + + attachment := db.AssertExistsAndLoadBean(t, &models.Attachment{ID: 1}).(*models.Attachment) + repo := db.AssertExistsAndLoadBean(t, &models.Repository{ID: attachment.RepoID}).(*models.Repository) + issue := db.AssertExistsAndLoadBean(t, &models.Issue{RepoID: attachment.IssueID}).(*models.Issue) + repoOwner := db.AssertExistsAndLoadBean(t, &models.User{ID: repo.OwnerID}).(*models.User) + + session := loginUser(t, repoOwner.Name) + token := getTokenForLoggedInUser(t, session) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/assets?token=%s", + repoOwner.Name, repo.Name, issue.Index, token) + + req := NewRequest(t, "GET", urlStr) + resp := session.MakeRequest(t, req, http.StatusOK) + apiAttachment := new([]api.Attachment) + DecodeJSON(t, resp, &apiAttachment) + + db.AssertExistsAndLoadBean(t, &models.Attachment{ID: (*apiAttachment)[0].ID, IssueID: issue.ID}) +} + +func TestAPICreateIssueAttachment(t *testing.T) { + defer prepareTestEnv(t)() + + repo := db.AssertExistsAndLoadBean(t, &models.Repository{ID: 1}).(*models.Repository) + issue := db.AssertExistsAndLoadBean(t, &models.Issue{RepoID: repo.ID}).(*models.Issue) + repoOwner := db.AssertExistsAndLoadBean(t, &models.User{ID: repo.OwnerID}).(*models.User) + + session := loginUser(t, repoOwner.Name) + token := getTokenForLoggedInUser(t, session) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/assets?token=%s", + repoOwner.Name, repo.Name, issue.Index, token) + + filename := "image.png" + buff := generateImg() + body := &bytes.Buffer{} + + // Setup multi-part + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile("attachment", filename) + assert.NoError(t, err) + _, err = io.Copy(part, &buff) + assert.NoError(t, err) + err = writer.Close() + assert.NoError(t, err) + + req := NewRequestWithBody(t, "POST", urlStr, body) + req.Header.Add("Content-Type", writer.FormDataContentType()) + resp := session.MakeRequest(t, req, http.StatusCreated) + + apiAttachment := new(api.Attachment) + DecodeJSON(t, resp, &apiAttachment) + + db.AssertExistsAndLoadBean(t, &models.Attachment{ID: apiAttachment.ID, IssueID: issue.ID}) +} + +func TestAPIEditIssueAttachment(t *testing.T) { + defer prepareTestEnv(t)() + const newAttachmentName = "newAttachmentName" + + attachment := db.AssertExistsAndLoadBean(t, &models.Attachment{ID: 1}).(*models.Attachment) + repo := db.AssertExistsAndLoadBean(t, &models.Repository{ID: attachment.RepoID}).(*models.Repository) + issue := db.AssertExistsAndLoadBean(t, &models.Issue{RepoID: attachment.IssueID}).(*models.Issue) + repoOwner := db.AssertExistsAndLoadBean(t, &models.User{ID: repo.OwnerID}).(*models.User) + + session := loginUser(t, repoOwner.Name) + token := getTokenForLoggedInUser(t, session) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/assets/%d?token=%s", + repoOwner.Name, repo.Name, issue.Index, attachment.ID, token) + req := NewRequestWithValues(t, "PATCH", urlStr, map[string]string{ + "name": newAttachmentName, + }) + resp := session.MakeRequest(t, req, http.StatusCreated) + apiAttachment := new(api.Attachment) + DecodeJSON(t, resp, &apiAttachment) + + db.AssertExistsAndLoadBean(t, &models.Attachment{ID: apiAttachment.ID, IssueID: issue.ID, Name: apiAttachment.Name}) +} + +func TestAPIDeleteIssueAttachment(t *testing.T) { + defer prepareTestEnv(t)() + + attachment := db.AssertExistsAndLoadBean(t, &models.Attachment{ID: 1}).(*models.Attachment) + repo := db.AssertExistsAndLoadBean(t, &models.Repository{ID: attachment.RepoID}).(*models.Repository) + issue := db.AssertExistsAndLoadBean(t, &models.Issue{RepoID: attachment.IssueID}).(*models.Issue) + repoOwner := db.AssertExistsAndLoadBean(t, &models.User{ID: repo.OwnerID}).(*models.User) + + session := loginUser(t, repoOwner.Name) + token := getTokenForLoggedInUser(t, session) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/assets/%d?token=%s", + repoOwner.Name, repo.Name, issue.Index, attachment.ID, token) + + req := NewRequest(t, "DELETE", urlStr) + session.MakeRequest(t, req, http.StatusNoContent) + + db.AssertNotExistsBean(t, &models.Attachment{ID: attachment.ID, IssueID: issue.ID}) +} diff --git a/models/issue_comment.go b/models/issue_comment.go index 39c2818eed049..d51a645a79fd8 100644 --- a/models/issue_comment.go +++ b/models/issue_comment.go @@ -845,18 +845,21 @@ func updateCommentInfos(ctx context.Context, opts *CreateCommentOptions, comment fallthrough case CommentTypeReview: // Check attachments - attachments, err := repo_model.GetAttachmentsByUUIDs(ctx, opts.Attachments) - if err != nil { - return fmt.Errorf("getAttachmentsByUUIDs [uuids: %v]: %v", opts.Attachments, err) - } + if len(opts.Attachments) > 0 { + attachments, err := repo_model.GetAttachmentsByUUIDs(ctx, opts.Attachments) + if err != nil { + return fmt.Errorf("getAttachmentsByUUIDs [uuids: %v]: %v", opts.Attachments, err) + } - for i := range attachments { - attachments[i].IssueID = opts.Issue.ID - attachments[i].CommentID = comment.ID - // No assign value could be 0, so ignore AllCols(). - if _, err = e.ID(attachments[i].ID).Update(attachments[i]); err != nil { - return fmt.Errorf("update attachment [%d]: %v", attachments[i].ID, err) + for i := range attachments { + attachments[i].IssueID = opts.Issue.ID + attachments[i].CommentID = comment.ID + // No assign value could be 0, so ignore AllCols(). + if _, err = e.ID(attachments[i].ID).Update(attachments[i]); err != nil { + return fmt.Errorf("update attachment [%d]: %v", attachments[i].ID, err) + } } + comment.Attachments = attachments } case CommentTypeReopen, CommentTypeClose: if err = updateIssueClosedNum(ctx, opts.Issue); err != nil { diff --git a/models/repo/attachment.go b/models/repo/attachment.go index f5351578fbc5c..bb844974d6e5a 100644 --- a/models/repo/attachment.go +++ b/models/repo/attachment.go @@ -225,11 +225,6 @@ func DeleteAttachmentsByComment(commentID int64, remove bool) (int, error) { return DeleteAttachments(db.DefaultContext, attachments, remove) } -// UpdateAttachment updates the given attachment in database -func UpdateAttachment(atta *Attachment) error { - return UpdateAttachmentCtx(db.DefaultContext, atta) -} - // UpdateAttachmentByUUID Updates attachment via uuid func UpdateAttachmentByUUID(ctx context.Context, attach *Attachment, cols ...string) error { if attach.UUID == "" { diff --git a/models/repo/attachment_test.go b/models/repo/attachment_test.go index 53c28d5324f8f..ff011090c0922 100644 --- a/models/repo/attachment_test.go +++ b/models/repo/attachment_test.go @@ -86,7 +86,7 @@ func TestUpdateAttachment(t *testing.T) { assert.Equal(t, "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11", attach.UUID) attach.Name = "new_name" - assert.NoError(t, UpdateAttachment(attach)) + assert.NoError(t, UpdateAttachmentCtx(db.DefaultContext, attach)) unittest.AssertExistsAndLoadBean(t, &Attachment{Name: "new_name"}) } diff --git a/modules/convert/attachment.go b/modules/convert/attachment.go new file mode 100644 index 0000000000000..9b82e9da8fde4 --- /dev/null +++ b/modules/convert/attachment.go @@ -0,0 +1,23 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package convert + +import ( + repo_model "code.gitea.io/gitea/models/repo" + api "code.gitea.io/gitea/modules/structs" +) + +// ToAttachment converts models.Attachment to api.Attachment +func ToAttachment(a *repo_model.Attachment) *api.Attachment { + return &api.Attachment{ + ID: a.ID, + Name: a.Name, + Created: a.CreatedUnix.AsTime(), + DownloadCount: a.DownloadCount, + Size: a.Size, + UUID: a.UUID, + DownloadURL: a.DownloadURL(), + } +} diff --git a/modules/convert/issue.go b/modules/convert/issue.go index 6cdb10f7daa6f..ab06ff465d50a 100644 --- a/modules/convert/issue.go +++ b/modules/convert/issue.go @@ -37,21 +37,27 @@ func ToAPIIssue(issue *models.Issue) *api.Issue { return &api.Issue{} } + assets := make([]*api.Attachment, 0, len(issue.Attachments)) + for _, att := range issue.Attachments { + assets = append(assets, ToAttachment(att)) + } + apiIssue := &api.Issue{ - ID: issue.ID, - URL: issue.APIURL(), - HTMLURL: issue.HTMLURL(), - Index: issue.Index, - Poster: ToUser(issue.Poster, nil), - Title: issue.Title, - Body: issue.Content, - Ref: issue.Ref, - Labels: ToLabelList(issue.Labels, issue.Repo, issue.Repo.Owner), - State: issue.State(), - IsLocked: issue.IsLocked, - Comments: issue.NumComments, - Created: issue.CreatedUnix.AsTime(), - Updated: issue.UpdatedUnix.AsTime(), + ID: issue.ID, + URL: issue.APIURL(), + HTMLURL: issue.HTMLURL(), + Index: issue.Index, + Poster: ToUser(issue.Poster, nil), + Title: issue.Title, + Body: issue.Content, + Attachments: assets, + Ref: issue.Ref, + Labels: ToLabelList(issue.Labels, issue.Repo, issue.Repo.Owner), + State: issue.State(), + IsLocked: issue.IsLocked, + Comments: issue.NumComments, + Created: issue.CreatedUnix.AsTime(), + Updated: issue.UpdatedUnix.AsTime(), } apiIssue.Repo = &api.RepositoryMeta{ diff --git a/modules/convert/issue_comment.go b/modules/convert/issue_comment.go index caba2b506e435..6c79a11d63d34 100644 --- a/modules/convert/issue_comment.go +++ b/modules/convert/issue_comment.go @@ -14,15 +14,20 @@ import ( // ToComment converts a models.Comment to the api.Comment format func ToComment(c *models.Comment) *api.Comment { + assets := make([]*api.Attachment, 0, len(c.Attachments)) + for _, att := range c.Attachments { + assets = append(assets, ToAttachment(att)) + } return &api.Comment{ - ID: c.ID, - Poster: ToUser(c.Poster, nil), - HTMLURL: c.HTMLURL(), - IssueURL: c.IssueURL(), - PRURL: c.PRURL(), - Body: c.Content, - Created: c.CreatedUnix.AsTime(), - Updated: c.UpdatedUnix.AsTime(), + ID: c.ID, + Poster: ToUser(c.Poster, nil), + HTMLURL: c.HTMLURL(), + IssueURL: c.IssueURL(), + PRURL: c.PRURL(), + Body: c.Content, + Attachments: assets, + Created: c.CreatedUnix.AsTime(), + Updated: c.UpdatedUnix.AsTime(), } } diff --git a/modules/convert/release.go b/modules/convert/release.go index 955d3ff05fb98..3f997e62fbd3c 100644 --- a/modules/convert/release.go +++ b/modules/convert/release.go @@ -6,7 +6,6 @@ package convert import ( "code.gitea.io/gitea/models" - repo_model "code.gitea.io/gitea/models/repo" api "code.gitea.io/gitea/modules/structs" ) @@ -14,7 +13,7 @@ import ( func ToRelease(r *models.Release) *api.Release { assets := make([]*api.Attachment, 0) for _, att := range r.Attachments { - assets = append(assets, ToReleaseAttachment(att)) + assets = append(assets, ToAttachment(att)) } return &api.Release{ ID: r.ID, @@ -34,16 +33,3 @@ func ToRelease(r *models.Release) *api.Release { Attachments: assets, } } - -// ToReleaseAttachment converts models.Attachment to api.Attachment -func ToReleaseAttachment(a *repo_model.Attachment) *api.Attachment { - return &api.Attachment{ - ID: a.ID, - Name: a.Name, - Created: a.CreatedUnix.AsTime(), - DownloadCount: a.DownloadCount, - Size: a.Size, - UUID: a.UUID, - DownloadURL: a.DownloadURL(), - } -} diff --git a/modules/notification/webhook/webhook.go b/modules/notification/webhook/webhook.go index d24440d585c79..28c69a177be3c 100644 --- a/modules/notification/webhook/webhook.go +++ b/modules/notification/webhook/webhook.go @@ -337,6 +337,11 @@ func (m *webhookNotifier) NotifyIssueChangeContent(doer *user_model.User, issue ctx, _, finished := process.GetManager().AddContext(graceful.GetManager().HammerContext(), fmt.Sprintf("webhook.NotifyIssueChangeContent User: %s[%d] Issue[%d] #%d in [%d]", doer.Name, doer.ID, issue.ID, issue.Index, issue.RepoID)) defer finished() + if err := issue.LoadRepo(ctx); err != nil { + log.Error("NotifyIssueChangeContent: coulnd't load repo", err) + return + } + mode, _ := models.AccessLevel(issue.Poster, issue.Repo) var err error if issue.IsPull { diff --git a/modules/structs/issue.go b/modules/structs/issue.go index c72487fe4dcad..b58f84b5401cb 100644 --- a/modules/structs/issue.go +++ b/modules/structs/issue.go @@ -38,18 +38,19 @@ type RepositoryMeta struct { // Issue represents an issue in a repository // swagger:model type Issue struct { - ID int64 `json:"id"` - URL string `json:"url"` - HTMLURL string `json:"html_url"` - Index int64 `json:"number"` - Poster *User `json:"user"` - OriginalAuthor string `json:"original_author"` - OriginalAuthorID int64 `json:"original_author_id"` - Title string `json:"title"` - Body string `json:"body"` - Ref string `json:"ref"` - Labels []*Label `json:"labels"` - Milestone *Milestone `json:"milestone"` + ID int64 `json:"id"` + URL string `json:"url"` + HTMLURL string `json:"html_url"` + Index int64 `json:"number"` + Poster *User `json:"user"` + OriginalAuthor string `json:"original_author"` + OriginalAuthorID int64 `json:"original_author_id"` + Title string `json:"title"` + Body string `json:"body"` + Ref string `json:"ref"` + Attachments []*Attachment `json:"assets"` + Labels []*Label `json:"labels"` + Milestone *Milestone `json:"milestone"` // deprecated Assignee *User `json:"assignee"` Assignees []*User `json:"assignees"` diff --git a/modules/structs/issue_comment.go b/modules/structs/issue_comment.go index e13ec05d01856..bff14f39b92bb 100644 --- a/modules/structs/issue_comment.go +++ b/modules/structs/issue_comment.go @@ -10,14 +10,15 @@ import ( // Comment represents a comment on a commit or issue type Comment struct { - ID int64 `json:"id"` - HTMLURL string `json:"html_url"` - PRURL string `json:"pull_request_url"` - IssueURL string `json:"issue_url"` - Poster *User `json:"user"` - OriginalAuthor string `json:"original_author"` - OriginalAuthorID int64 `json:"original_author_id"` - Body string `json:"body"` + ID int64 `json:"id"` + HTMLURL string `json:"html_url"` + PRURL string `json:"pull_request_url"` + IssueURL string `json:"issue_url"` + Poster *User `json:"user"` + OriginalAuthor string `json:"original_author"` + OriginalAuthorID int64 `json:"original_author_id"` + Body string `json:"body"` + Attachments []*Attachment `json:"assets"` // swagger:strfmt date-time Created time.Time `json:"created_at"` // swagger:strfmt date-time diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index aec2a6d7b2c42..03be942ab0540 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -557,6 +557,13 @@ func mustNotBeArchived(ctx *context.APIContext) { } } +func mustEnableAttachments(ctx *context.APIContext) { + if !setting.Attachment.Enabled { + ctx.NotFound() + return + } +} + // bind binding an obj to a func(ctx *context.APIContext) func bind(obj interface{}) http.HandlerFunc { tp := reflect.TypeOf(obj) @@ -871,6 +878,15 @@ func Routes() *web.Route { Get(repo.GetIssueCommentReactions). Post(reqToken(), bind(api.EditReactionOption{}), repo.PostIssueCommentReaction). Delete(reqToken(), bind(api.EditReactionOption{}), repo.DeleteIssueCommentReaction) + m.Group("/assets", func() { + m.Combo(""). + Get(repo.ListIssueCommentAttachments). + Post(reqToken(), mustNotBeArchived, repo.CreateIssueCommentAttachment) + m.Combo("/{asset}"). + Get(repo.GetIssueCommentAttachment). + Patch(reqToken(), mustNotBeArchived, bind(api.EditAttachmentOptions{}), repo.EditIssueCommentAttachment). + Delete(reqToken(), mustNotBeArchived, repo.DeleteIssueCommentAttachment) + }, mustEnableAttachments) }) }) m.Group("/{index}", func() { @@ -914,6 +930,15 @@ func Routes() *web.Route { Get(repo.GetIssueReactions). Post(reqToken(), bind(api.EditReactionOption{}), repo.PostIssueReaction). Delete(reqToken(), bind(api.EditReactionOption{}), repo.DeleteIssueReaction) + m.Group("/assets", func() { + m.Combo(""). + Get(repo.ListIssueAttachments). + Post(reqToken(), mustNotBeArchived, repo.CreateIssueAttachment) + m.Combo("/{asset}"). + Get(repo.GetIssueAttachment). + Patch(reqToken(), mustNotBeArchived, bind(api.EditAttachmentOptions{}), repo.EditIssueAttachment). + Delete(reqToken(), mustNotBeArchived, repo.DeleteIssueAttachment) + }, mustEnableAttachments) }) }, mustEnableIssuesOrPulls) m.Group("/labels", func() { diff --git a/routers/api/v1/repo/issue_attachment.go b/routers/api/v1/repo/issue_attachment.go new file mode 100644 index 0000000000000..0213f578e67eb --- /dev/null +++ b/routers/api/v1/repo/issue_attachment.go @@ -0,0 +1,377 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package repo + +import ( + "net/http" + + "code.gitea.io/gitea/models" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/convert" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/attachment" + issue_service "code.gitea.io/gitea/services/issue" +) + +/** + * NOTE about permissions: + * - repo access is already checked via middleware on the /repos/{owner}/{name} group + * - issue/pull *read* access is checked on the ../issues group middleware + * ("read" access allows posting issues, so posting attachments is fine too!) + * - setting.Attachment.Enabled is checked on ../assets group middleware + * All that is left to be checked is + * - canUserWriteIssueAttachment() + * - attachmentBelongsToIssue() + */ + +// GetIssueAttachment gets a single attachment of the issue +func GetIssueAttachment(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/issues/{index}/assets/{attachment_id} issue issueGetIssueAttachment + // --- + // summary: Get an issue attachment + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: index + // in: path + // description: index of the issue + // type: integer + // format: int64 + // required: true + // - name: attachment_id + // in: path + // description: id of the attachment to get + // type: integer + // format: int64 + // required: true + // responses: + // "200": + // "$ref": "#/responses/Attachment" + // "404": + // "$ref": "#/responses/error" + + issueIndex := ctx.ParamsInt64(":index") + issue, err := models.GetIssueByIndex(ctx.Repo.Repository.ID, issueIndex) + if err != nil { + ctx.NotFoundOrServerError("GetIssueByID", models.IsErrIssueNotExist, err) + return + } + + attach := getIssueAttachmentSafeRead(ctx, issue) + if attach == nil { + return + } + ctx.JSON(http.StatusOK, convert.ToAttachment(attach)) +} + +// ListIssueAttachments lists all attachments of the issue +func ListIssueAttachments(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/issues/{index}/assets issue issueListIssueAttachments + // --- + // summary: List issue's attachments + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: index + // in: path + // description: index of the issue + // type: integer + // format: int64 + // required: true + // responses: + // "200": + // "$ref": "#/responses/AttachmentList" + // "404": + // "$ref": "#/responses/error" + + issue, err := models.GetIssueByIndex(ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + ctx.NotFoundOrServerError("GetIssueByIndex", models.IsErrIssueNotExist, err) + return + } + if err := issue.LoadAttributes(); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadAttributes", err) + return + } + ctx.JSON(http.StatusOK, convert.ToAPIIssue(issue).Attachments) +} + +// CreateIssueAttachment creates an attachment and saves the given file +func CreateIssueAttachment(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/issues/{index}/assets issue issueCreateIssueAttachment + // --- + // summary: Create an issue attachment + // produces: + // - application/json + // consumes: + // - multipart/form-data + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: index + // in: path + // description: index of the issue + // type: integer + // format: int64 + // required: true + // - name: name + // in: query + // description: name of the attachment + // type: string + // required: false + // - name: attachment + // in: formData + // description: attachment to upload + // type: file + // required: true + // responses: + // "201": + // "$ref": "#/responses/Attachment" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/error" + + // Check if issue exists and load issue + issue, err := models.GetIssueByIndex(ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + ctx.NotFoundOrServerError("GetIssueByIndex", models.IsErrIssueNotExist, err) + return + } + + if !canUserWriteIssueAttachment(ctx, issue) { + return + } + + // Get uploaded file from request + file, header, err := ctx.Req.FormFile("attachment") + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetFile", err) + return + } + defer file.Close() + + filename := header.Filename + if query := ctx.FormString("name"); query != "" { + filename = query + } + + attach, err := attachment.UploadAttachment(file, setting.Attachment.AllowedTypes, &repo_model.Attachment{ + Name: filename, + UploaderID: ctx.Doer.ID, + RepoID: ctx.Repo.Repository.ID, + IssueID: issue.ID, + }) + if err != nil { + ctx.Error(http.StatusInternalServerError, "UploadAttachment", err) + return + } + + issue.Attachments = append(issue.Attachments, attach) + + if err := issue_service.ChangeContent(issue, ctx.Doer, issue.Content); err != nil { + ctx.Error(http.StatusInternalServerError, "ChangeContent", err) + return + } + + ctx.JSON(http.StatusCreated, convert.ToAttachment(attach)) +} + +// EditIssueAttachment updates the given attachment +func EditIssueAttachment(ctx *context.APIContext) { + // swagger:operation PATCH /repos/{owner}/{repo}/issues/{index}/assets/{attachment_id} issue issueEditIssueAttachment + // --- + // summary: Edit an issue attachment + // produces: + // - application/json + // consumes: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: index + // in: path + // description: index of the issue + // type: integer + // format: int64 + // required: true + // - name: attachment_id + // in: path + // description: id of the attachment to edit + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/EditAttachmentOptions" + // responses: + // "201": + // "$ref": "#/responses/Attachment" + // "404": + // "$ref": "#/responses/error" + + // get attachment and check permissions + attach := getIssueAttachmentSafeWrite(ctx) + if attach == nil { + return + } + // do changes to attachment. only meaningful change is name. + form := web.GetForm(ctx).(*api.EditAttachmentOptions) + if form.Name != "" { + attach.Name = form.Name + } + if err := repo_model.UpdateAttachmentCtx(ctx, attach); err != nil { + ctx.Error(http.StatusInternalServerError, "UpdateAttachment", err) + } + ctx.JSON(http.StatusCreated, convert.ToAttachment(attach)) +} + +// DeleteIssueAttachment delete a given attachment +func DeleteIssueAttachment(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/issues/{index}/assets/{attachment_id} issue issueDeleteIssueAttachment + // --- + // summary: Delete an issue attachment + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: index + // in: path + // description: index of the issue + // type: integer + // format: int64 + // required: true + // - name: attachment_id + // in: path + // description: id of the attachment to delete + // type: integer + // format: int64 + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/error" + + attach := getIssueAttachmentSafeWrite(ctx) + if attach == nil { + return + } + if err := repo_model.DeleteAttachment(attach, true); err != nil { + ctx.Error(http.StatusInternalServerError, "DeleteAttachment", err) + return + } + ctx.Status(http.StatusNoContent) +} + +func getIssueAttachmentSafeWrite(ctx *context.APIContext) *repo_model.Attachment { + issueIndex := ctx.ParamsInt64(":index") + issue, err := models.GetIssueByIndex(ctx.Repo.Repository.ID, issueIndex) + if err != nil { + ctx.NotFoundOrServerError("GetIssueByIndex", models.IsErrIssueNotExist, err) + return nil + } + if !canUserWriteIssueAttachment(ctx, issue) { + return nil + } + + attach := getIssueAttachmentSafeRead(ctx, issue) + if attach == nil { + return nil + } + return attach +} + +func getIssueAttachmentSafeRead(ctx *context.APIContext, issue *models.Issue) *repo_model.Attachment { + attachID := ctx.ParamsInt64(":asset") + attach, err := repo_model.GetAttachmentByID(attachID) + if err != nil { + ctx.NotFoundOrServerError("GetAttachmentByID", repo_model.IsErrAttachmentNotExist, err) + return nil + } + if !attachmentBelongsToRepoOrIssue(ctx, attach, issue) { + return nil + } + return attach +} + +func canUserWriteIssueAttachment(ctx *context.APIContext, i *models.Issue) (success bool) { + canEditIssue := ctx.Doer.ID == i.PosterID || ctx.IsUserRepoAdmin() || ctx.IsUserSiteAdmin() + if !canEditIssue { + ctx.Error(http.StatusForbidden, "IssueEditPerm", "user should have permission to edit issue") + return + } + + return true +} + +func attachmentBelongsToRepoOrIssue(ctx *context.APIContext, a *repo_model.Attachment, issue *models.Issue) (success bool) { + if a.RepoID != ctx.Repo.Repository.ID { + log.Debug("Requested attachment[%d] does not belong to repo[%-v].", a.ID, ctx.Repo.Repository) + ctx.NotFound("no such attachment in repo") + return + } + if a.IssueID == 0 { + // catch people trying to get release assets ;) + log.Debug("Requested attachment[%d] is not in an issue.", a.ID) + ctx.NotFound("no such attachment in issue") + return + } else if issue != nil && a.IssueID != issue.ID { + log.Debug("Requested attachment[%d] does not belong to issue[%d, #%d].", a.ID, issue.ID, issue.Index) + ctx.NotFound("no such attachment in issue") + return + } + return true +} diff --git a/routers/api/v1/repo/issue_comment.go b/routers/api/v1/repo/issue_comment.go index ef91a2481c273..666e976c3c177 100644 --- a/routers/api/v1/repo/issue_comment.go +++ b/routers/api/v1/repo/issue_comment.go @@ -94,6 +94,11 @@ func ListIssueComments(ctx *context.APIContext) { return } + if err := models.CommentList(comments).LoadAttachments(); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadAttachments", err) + return + } + apiComments := make([]*api.Comment, len(comments)) for i, comment := range comments { comment.Issue = issue @@ -293,6 +298,10 @@ func ListRepoIssueComments(ctx *context.APIContext) { ctx.Error(http.StatusInternalServerError, "LoadPosters", err) return } + if err := models.CommentList(comments).LoadAttachments(); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadAttachments", err) + return + } if _, err := models.CommentList(comments).Issues().LoadRepositories(); err != nil { ctx.Error(http.StatusInternalServerError, "LoadRepositories", err) return diff --git a/routers/api/v1/repo/issue_comment_attachment.go b/routers/api/v1/repo/issue_comment_attachment.go new file mode 100644 index 0000000000000..f5e3d0aa44bc0 --- /dev/null +++ b/routers/api/v1/repo/issue_comment_attachment.go @@ -0,0 +1,389 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package repo + +import ( + "net/http" + + "code.gitea.io/gitea/models" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/convert" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/attachment" + comment_service "code.gitea.io/gitea/services/comments" +) + +// GetIssueCommentAttachment gets a single attachment of the comment +func GetIssueCommentAttachment(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/issues/comments/{id}/assets/{attachment_id} issue issueGetIssueCommentAttachment + // --- + // summary: Get a comment attachment + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: id + // in: path + // description: id of the comment + // type: integer + // format: int64 + // required: true + // - name: attachment_id + // in: path + // description: id of the attachment to get + // type: integer + // format: int64 + // required: true + // responses: + // "200": + // "$ref": "#/responses/Attachment" + // "404": + // "$ref": "#/responses/error" + + comment := getIssueCommentSafe(ctx) + if comment == nil { + return + } + attach := getIssueCommentAttachmentSafeRead(ctx, comment) + if attach == nil { + return + } + if attach.CommentID != comment.ID { + log.Debug("User requested attachment[%d] is not in comment[%d].", attach.ID, comment.ID) + ctx.NotFound("attachment not in comment") + return + } + + ctx.JSON(http.StatusOK, convert.ToAttachment(attach)) +} + +// ListIssueCommentAttachments lists all attachments of the comment +func ListIssueCommentAttachments(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/issues/comments/{id}/assets issue issueListIssueCommentAttachments + // --- + // summary: List comment's attachments + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: id + // in: path + // description: id of the comment + // type: integer + // format: int64 + // required: true + // responses: + // "200": + // "$ref": "#/responses/AttachmentList" + // "404": + // "$ref": "#/responses/error" + comment := getIssueCommentSafe(ctx) + if comment == nil { + return + } + + if err := comment.LoadAttachments(); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadAttachments", err) + return + } + + ctx.JSON(http.StatusOK, convert.ToComment(comment).Attachments) +} + +// CreateIssueCommentAttachment creates an attachment and saves the given file +func CreateIssueCommentAttachment(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/issues/comments/{id}/assets issue issueCreateIssueCommentAttachment + // --- + // summary: Create a comment attachment + // produces: + // - application/json + // consumes: + // - multipart/form-data + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: id + // in: path + // description: id of the comment + // type: integer + // format: int64 + // required: true + // - name: name + // in: query + // description: name of the attachment + // type: string + // required: false + // - name: attachment + // in: formData + // description: attachment to upload + // type: file + // required: true + // responses: + // "201": + // "$ref": "#/responses/Attachment" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/error" + + // Check if comment exists and load comment + comment := getIssueCommentSafe(ctx) + if comment == nil { + return + } + + if !canUserWriteIssueCommentAttachment(ctx, comment) { + return + } + + // Get uploaded file from request + file, header, err := ctx.Req.FormFile("attachment") + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetFile", err) + return + } + defer file.Close() + + filename := header.Filename + if query := ctx.FormString("name"); query != "" { + filename = query + } + + attach, err := attachment.UploadAttachment(file, setting.Attachment.AllowedTypes, &repo_model.Attachment{ + Name: filename, + UploaderID: ctx.Doer.ID, + RepoID: ctx.Repo.Repository.ID, + IssueID: comment.IssueID, + CommentID: comment.ID, + }) + if err != nil { + ctx.Error(http.StatusInternalServerError, "UploadAttachment", err) + return + } + if err := comment.LoadAttachments(); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadAttachments", err) + return + } + + if err = comment_service.UpdateComment(comment, ctx.Doer, comment.Content); err != nil { + ctx.ServerError("UpdateComment", err) + return + } + + ctx.JSON(http.StatusCreated, convert.ToAttachment(attach)) +} + +// EditIssueCommentAttachment updates the given attachment +func EditIssueCommentAttachment(ctx *context.APIContext) { + // swagger:operation PATCH /repos/{owner}/{repo}/issues/comments/{id}/assets/{attachment_id} issue issueEditIssueCommentAttachment + // --- + // summary: Edit a comment attachment + // produces: + // - application/json + // consumes: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: id + // in: path + // description: id of the comment + // type: integer + // format: int64 + // required: true + // - name: attachment_id + // in: path + // description: id of the attachment to edit + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/EditAttachmentOptions" + // responses: + // "201": + // "$ref": "#/responses/Attachment" + // "404": + // "$ref": "#/responses/error" + + attach := getIssueCommentAttachmentSafeWrite(ctx) + if attach == nil { + return + } + + form := web.GetForm(ctx).(*api.EditAttachmentOptions) + if form.Name != "" { + attach.Name = form.Name + } + + if err := repo_model.UpdateAttachmentCtx(ctx, attach); err != nil { + ctx.Error(http.StatusInternalServerError, "UpdateAttachment", attach) + } + ctx.JSON(http.StatusCreated, convert.ToAttachment(attach)) +} + +// DeleteIssueCommentAttachment delete a given attachment +func DeleteIssueCommentAttachment(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/issues/comments/{id}/assets/{attachment_id} issue issueDeleteIssueCommentAttachment + // --- + // summary: Delete a comment attachment + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: id + // in: path + // description: id of the comment + // type: integer + // format: int64 + // required: true + // - name: attachment_id + // in: path + // description: id of the attachment to delete + // type: integer + // format: int64 + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/error" + + attach := getIssueCommentAttachmentSafeWrite(ctx) + if attach == nil { + return + } + + if err := repo_model.DeleteAttachment(attach, true); err != nil { + ctx.Error(http.StatusInternalServerError, "DeleteAttachment", err) + return + } + ctx.Status(http.StatusNoContent) +} + +func getIssueCommentSafe(ctx *context.APIContext) *models.Comment { + comment, err := models.GetCommentByID(ctx.ParamsInt64(":id")) + if err != nil { + ctx.NotFoundOrServerError("GetCommentByID", models.IsErrCommentNotExist, err) + return nil + } + // deny accessing arbitrary comments via this API + // TODO: if issue ID were available on context, we could check that too. + if err := comment.LoadIssue(); err != nil { + ctx.Error(http.StatusInternalServerError, "comment.LoadIssue", err) + return nil + } + if comment.Issue == nil || comment.Issue.RepoID != ctx.Repo.Repository.ID { + ctx.Error(http.StatusNotFound, "", "no matching issue comment found") + return nil + } + return comment +} + +func getIssueCommentAttachmentSafeWrite(ctx *context.APIContext) *repo_model.Attachment { + comment := getIssueCommentSafe(ctx) + if comment == nil { + return nil + } + if !canUserWriteIssueCommentAttachment(ctx, comment) { + return nil + } + attach := getIssueCommentAttachmentSafeRead(ctx, comment) + if attach == nil { + return nil + } + return attach +} + +func getIssueCommentAttachmentSafeRead(ctx *context.APIContext, comment *models.Comment) *repo_model.Attachment { + attachID := ctx.ParamsInt64(":asset") + attach, err := repo_model.GetAttachmentByID(attachID) + if err != nil { + ctx.NotFoundOrServerError("GetAttachmentByID", repo_model.IsErrAttachmentNotExist, err) + return nil + } + if !attachmentBelongsToRepoOrComment(ctx, attach, comment) { + return nil + } + return attach +} + +func canUserWriteIssueCommentAttachment(ctx *context.APIContext, c *models.Comment) (success bool) { + canEditComment := ctx.Doer.ID == c.PosterID || ctx.IsUserRepoAdmin() || ctx.IsUserSiteAdmin() + if !canEditComment { + ctx.Error(http.StatusForbidden, "", "user should have permission to edit comment") + return + } + + return true +} + +func attachmentBelongsToRepoOrComment(ctx *context.APIContext, a *repo_model.Attachment, comment *models.Comment) (success bool) { + if a.RepoID != ctx.Repo.Repository.ID { + log.Debug("Requested attachment[%d] does not belong to repo[%-v].", a.ID, ctx.Repo.Repository) + ctx.NotFound("no such attachment in repo") + return + } + if a.IssueID == 0 || a.CommentID == 0 { + // catch people trying to get release assets ;) + log.Debug("Requested attachment[%d] is not in a comment.", a.ID) + ctx.NotFound("no such attachment in comment") + return + } + if comment != nil && a.CommentID != comment.ID { + log.Debug("Requested attachment[%d] does not belong to comment[%d].", a.ID, comment.ID) + ctx.NotFound("no such attachment in comment") + return + } + return true +} diff --git a/routers/api/v1/repo/release_attachment.go b/routers/api/v1/repo/release_attachment.go index c172b66127244..c19d307441540 100644 --- a/routers/api/v1/repo/release_attachment.go +++ b/routers/api/v1/repo/release_attachment.go @@ -66,7 +66,7 @@ func GetReleaseAttachment(ctx *context.APIContext) { return } // FIXME Should prove the existence of the given repo, but results in unnecessary database requests - ctx.JSON(http.StatusOK, convert.ToReleaseAttachment(attach)) + ctx.JSON(http.StatusOK, convert.ToAttachment(attach)) } // ListReleaseAttachments lists all attachments of the release @@ -184,7 +184,12 @@ func CreateReleaseAttachment(ctx *context.APIContext) { } // Create a new attachment and save the file - attach, err := attachment.UploadAttachment(file, ctx.Doer.ID, release.RepoID, releaseID, filename, setting.Repository.Release.AllowedTypes) + attach, err := attachment.UploadAttachment(file, setting.Repository.Release.AllowedTypes, &repo_model.Attachment{ + Name: filename, + UploaderID: ctx.Doer.ID, + RepoID: release.RepoID, + ReleaseID: releaseID, + }) if err != nil { if upload.IsErrFileTypeForbidden(err) { ctx.Error(http.StatusBadRequest, "DetectContentType", err) @@ -194,7 +199,7 @@ func CreateReleaseAttachment(ctx *context.APIContext) { return } - ctx.JSON(http.StatusCreated, convert.ToReleaseAttachment(attach)) + ctx.JSON(http.StatusCreated, convert.ToAttachment(attach)) } // EditReleaseAttachment updates the given attachment @@ -257,10 +262,10 @@ func EditReleaseAttachment(ctx *context.APIContext) { attach.Name = form.Name } - if err := repo_model.UpdateAttachment(attach); err != nil { + if err := repo_model.UpdateAttachmentCtx(ctx, attach); err != nil { ctx.Error(http.StatusInternalServerError, "UpdateAttachment", attach) } - ctx.JSON(http.StatusCreated, convert.ToReleaseAttachment(attach)) + ctx.JSON(http.StatusCreated, convert.ToAttachment(attach)) } // DeleteReleaseAttachment delete a given attachment diff --git a/routers/web/repo/attachment.go b/routers/web/repo/attachment.go index be5b5812d3805..1c6caba85a0c1 100644 --- a/routers/web/repo/attachment.go +++ b/routers/web/repo/attachment.go @@ -44,7 +44,11 @@ func uploadAttachment(ctx *context.Context, repoID int64, allowedTypes string) { } defer file.Close() - attach, err := attachment.UploadAttachment(file, ctx.Doer.ID, repoID, 0, header.Filename, allowedTypes) + attach, err := attachment.UploadAttachment(file, allowedTypes, &repo_model.Attachment{ + Name: header.Filename, + UploaderID: ctx.Doer.ID, + RepoID: repoID, + }) if err != nil { if upload.IsErrFileTypeForbidden(err) { ctx.Error(http.StatusBadRequest, err.Error()) diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go index 3ca193a15e739..98b35c0cd1fc9 100644 --- a/routers/web/repo/issue.go +++ b/routers/web/repo/issue.go @@ -1849,12 +1849,19 @@ func UpdateIssueContent(ctx *context.Context) { // when update the request doesn't intend to update attachments (eg: change checkbox state), ignore attachment updates if !ctx.FormBool("ignore_attachments") { - if err := updateAttachments(issue, ctx.FormStrings("files[]")); err != nil { + files := ctx.FormStrings("files[]") + if err := updateAttachments(issue, files); err != nil { ctx.ServerError("UpdateAttachments", err) return } } + content := ctx.FormString("content") + if err := issue_service.ChangeContent(issue, ctx.Doer, content); err != nil { + ctx.ServerError("ChangeContent", err) + return + } + content, err := markdown.RenderString(&markup.RenderContext{ URLPrefix: ctx.FormString("context"), // FIXME: <- IS THIS SAFE ? Metas: ctx.Repo.Repository.ComposeMetas(), @@ -2635,10 +2642,6 @@ func UpdateCommentContent(ctx *context.Context) { }) return } - if err = comment_service.UpdateComment(comment, ctx.Doer, oldContent); err != nil { - ctx.ServerError("UpdateComment", err) - return - } if err := comment.LoadAttachments(); err != nil { ctx.ServerError("LoadAttachments", err) @@ -2653,6 +2656,11 @@ func UpdateCommentContent(ctx *context.Context) { } } + if err = comment_service.UpdateComment(comment, ctx.Doer, oldContent); err != nil { + ctx.ServerError("UpdateComment", err) + return + } + content, err := markdown.RenderString(&markup.RenderContext{ URLPrefix: ctx.FormString("context"), // FIXME: <- IS THIS SAFE ? Metas: ctx.Repo.Repository.ComposeMetas(), @@ -2936,7 +2944,7 @@ func GetIssueAttachments(ctx *context.Context) { issue := GetActionIssue(ctx) attachments := make([]*api.Attachment, len(issue.Attachments)) for i := 0; i < len(issue.Attachments); i++ { - attachments[i] = convert.ToReleaseAttachment(issue.Attachments[i]) + attachments[i] = convert.ToAttachment(issue.Attachments[i]) } ctx.JSON(http.StatusOK, attachments) } @@ -2955,7 +2963,7 @@ func GetCommentAttachments(ctx *context.Context) { return } for i := 0; i < len(comment.Attachments); i++ { - attachments = append(attachments, convert.ToReleaseAttachment(comment.Attachments[i])) + attachments = append(attachments, convert.ToAttachment(comment.Attachments[i])) } } ctx.JSON(http.StatusOK, attachments) diff --git a/services/attachment/attachment.go b/services/attachment/attachment.go index cce36206a765a..e65738d14face 100644 --- a/services/attachment/attachment.go +++ b/services/attachment/attachment.go @@ -40,19 +40,14 @@ func NewAttachment(attach *repo_model.Attachment, file io.Reader) (*repo_model.A } // UploadAttachment upload new attachment into storage and update database -func UploadAttachment(file io.Reader, actorID, repoID, releaseID int64, fileName, allowedTypes string) (*repo_model.Attachment, error) { +func UploadAttachment(file io.Reader, allowedTypes string, opts *repo_model.Attachment) (*repo_model.Attachment, error) { buf := make([]byte, 1024) n, _ := util.ReadAtMost(file, buf) buf = buf[:n] - if err := upload.Verify(buf, fileName, allowedTypes); err != nil { + if err := upload.Verify(buf, opts.Name, allowedTypes); err != nil { return nil, err } - return NewAttachment(&repo_model.Attachment{ - RepoID: repoID, - UploaderID: actorID, - ReleaseID: releaseID, - Name: fileName, - }, io.MultiReader(bytes.NewReader(buf), file)) + return NewAttachment(opts, io.MultiReader(bytes.NewReader(buf), file)) } diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 0374a53a6555c..33e56d2bfede7 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -4991,6 +4991,273 @@ } } }, + "/repos/{owner}/{repo}/issues/comments/{id}/assets": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "issue" + ], + "summary": "List comment's attachments", + "operationId": "issueListIssueCommentAttachments", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the comment", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/AttachmentList" + }, + "404": { + "$ref": "#/responses/error" + } + } + }, + "post": { + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "issue" + ], + "summary": "Create a comment attachment", + "operationId": "issueCreateIssueCommentAttachment", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the comment", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the attachment", + "name": "name", + "in": "query" + }, + { + "type": "file", + "description": "attachment to upload", + "name": "attachment", + "in": "formData", + "required": true + } + ], + "responses": { + "201": { + "$ref": "#/responses/Attachment" + }, + "400": { + "$ref": "#/responses/error" + }, + "404": { + "$ref": "#/responses/error" + } + } + } + }, + "/repos/{owner}/{repo}/issues/comments/{id}/assets/{attachment_id}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "issue" + ], + "summary": "Get a comment attachment", + "operationId": "issueGetIssueCommentAttachment", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the comment", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the attachment to get", + "name": "attachment_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/Attachment" + }, + "404": { + "$ref": "#/responses/error" + } + } + }, + "delete": { + "produces": [ + "application/json" + ], + "tags": [ + "issue" + ], + "summary": "Delete a comment attachment", + "operationId": "issueDeleteIssueCommentAttachment", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the comment", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the attachment to delete", + "name": "attachment_id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "404": { + "$ref": "#/responses/error" + } + } + }, + "patch": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "issue" + ], + "summary": "Edit a comment attachment", + "operationId": "issueEditIssueCommentAttachment", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the comment", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the attachment to edit", + "name": "attachment_id", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/EditAttachmentOptions" + } + } + ], + "responses": { + "201": { + "$ref": "#/responses/Attachment" + }, + "404": { + "$ref": "#/responses/error" + } + } + } + }, "/repos/{owner}/{repo}/issues/comments/{id}/reactions": { "get": { "consumes": [ @@ -5002,8 +5269,159 @@ "tags": [ "issue" ], - "summary": "Get a list of reactions from a comment of an issue", - "operationId": "issueGetCommentReactions", + "summary": "Get a list of reactions from a comment of an issue", + "operationId": "issueGetCommentReactions", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the comment to edit", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/ReactionList" + }, + "403": { + "$ref": "#/responses/forbidden" + } + } + }, + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "issue" + ], + "summary": "Add a reaction to a comment of an issue", + "operationId": "issuePostCommentReaction", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the comment to edit", + "name": "id", + "in": "path", + "required": true + }, + { + "name": "content", + "in": "body", + "schema": { + "$ref": "#/definitions/EditReactionOption" + } + } + ], + "responses": { + "200": { + "$ref": "#/responses/Reaction" + }, + "201": { + "$ref": "#/responses/Reaction" + }, + "403": { + "$ref": "#/responses/forbidden" + } + } + }, + "delete": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "issue" + ], + "summary": "Remove a reaction from a comment of an issue", + "operationId": "issueDeleteCommentReaction", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the comment to edit", + "name": "id", + "in": "path", + "required": true + }, + { + "name": "content", + "in": "body", + "schema": { + "$ref": "#/definitions/EditReactionOption" + } + } + ], + "responses": { + "200": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + } + } + } + }, + "/repos/{owner}/{repo}/issues/{index}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "issue" + ], + "summary": "Get an issue", + "operationId": "issueGetIssue", "parameters": [ { "type": "string", @@ -5022,22 +5440,64 @@ { "type": "integer", "format": "int64", - "description": "id of the comment to edit", - "name": "id", + "description": "index of the issue to get", + "name": "index", "in": "path", "required": true } ], "responses": { "200": { - "$ref": "#/responses/ReactionList" + "$ref": "#/responses/Issue" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "delete": { + "tags": [ + "issue" + ], + "summary": "Delete an issue", + "operationId": "issueDelete", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "index of issue to delete", + "name": "index", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" }, "403": { "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" } } }, - "post": { + "patch": { "consumes": [ "application/json" ], @@ -5047,8 +5507,8 @@ "tags": [ "issue" ], - "summary": "Add a reaction to a comment of an issue", - "operationId": "issuePostCommentReaction", + "summary": "Edit an issue. If using deadline only the date will be taken into account, and time of day ignored.", + "operationId": "issueEditIssue", "parameters": [ { "type": "string", @@ -5067,34 +5527,81 @@ { "type": "integer", "format": "int64", - "description": "id of the comment to edit", - "name": "id", + "description": "index of the issue to edit", + "name": "index", "in": "path", "required": true }, { - "name": "content", + "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/EditReactionOption" + "$ref": "#/definitions/EditIssueOption" } } ], "responses": { - "200": { - "$ref": "#/responses/Reaction" - }, "201": { - "$ref": "#/responses/Reaction" + "$ref": "#/responses/Issue" }, "403": { "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "412": { + "$ref": "#/responses/error" + } + } + } + }, + "/repos/{owner}/{repo}/issues/{index}/assets": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "issue" + ], + "summary": "List issue's attachments", + "operationId": "issueListIssueAttachments", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "index of the issue", + "name": "index", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/AttachmentList" + }, + "404": { + "$ref": "#/responses/error" } } }, - "delete": { + "post": { "consumes": [ - "application/json" + "multipart/form-data" ], "produces": [ "application/json" @@ -5102,8 +5609,8 @@ "tags": [ "issue" ], - "summary": "Remove a reaction from a comment of an issue", - "operationId": "issueDeleteCommentReaction", + "summary": "Create an issue attachment", + "operationId": "issueCreateIssueAttachment", "parameters": [ { "type": "string", @@ -5122,30 +5629,39 @@ { "type": "integer", "format": "int64", - "description": "id of the comment to edit", - "name": "id", + "description": "index of the issue", + "name": "index", "in": "path", "required": true }, { - "name": "content", - "in": "body", - "schema": { - "$ref": "#/definitions/EditReactionOption" - } + "type": "string", + "description": "name of the attachment", + "name": "name", + "in": "query" + }, + { + "type": "file", + "description": "attachment to upload", + "name": "attachment", + "in": "formData", + "required": true } ], "responses": { - "200": { - "$ref": "#/responses/empty" + "201": { + "$ref": "#/responses/Attachment" }, - "403": { - "$ref": "#/responses/forbidden" + "400": { + "$ref": "#/responses/error" + }, + "404": { + "$ref": "#/responses/error" } } } }, - "/repos/{owner}/{repo}/issues/{index}": { + "/repos/{owner}/{repo}/issues/{index}/assets/{attachment_id}": { "get": { "produces": [ "application/json" @@ -5153,8 +5669,8 @@ "tags": [ "issue" ], - "summary": "Get an issue", - "operationId": "issueGetIssue", + "summary": "Get an issue attachment", + "operationId": "issueGetIssueAttachment", "parameters": [ { "type": "string", @@ -5173,27 +5689,38 @@ { "type": "integer", "format": "int64", - "description": "index of the issue to get", + "description": "index of the issue", "name": "index", "in": "path", "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the attachment to get", + "name": "attachment_id", + "in": "path", + "required": true } ], "responses": { "200": { - "$ref": "#/responses/Issue" + "$ref": "#/responses/Attachment" }, "404": { - "$ref": "#/responses/notFound" + "$ref": "#/responses/error" } } }, "delete": { + "produces": [ + "application/json" + ], "tags": [ "issue" ], - "summary": "Delete an issue", - "operationId": "issueDelete", + "summary": "Delete an issue attachment", + "operationId": "issueDeleteIssueAttachment", "parameters": [ { "type": "string", @@ -5212,21 +5739,26 @@ { "type": "integer", "format": "int64", - "description": "index of issue to delete", + "description": "index of the issue", "name": "index", "in": "path", "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the attachment to delete", + "name": "attachment_id", + "in": "path", + "required": true } ], "responses": { "204": { "$ref": "#/responses/empty" }, - "403": { - "$ref": "#/responses/forbidden" - }, "404": { - "$ref": "#/responses/notFound" + "$ref": "#/responses/error" } } }, @@ -5240,8 +5772,8 @@ "tags": [ "issue" ], - "summary": "Edit an issue. If using deadline only the date will be taken into account, and time of day ignored.", - "operationId": "issueEditIssue", + "summary": "Edit an issue attachment", + "operationId": "issueEditIssueAttachment", "parameters": [ { "type": "string", @@ -5260,30 +5792,32 @@ { "type": "integer", "format": "int64", - "description": "index of the issue to edit", + "description": "index of the issue", "name": "index", "in": "path", "required": true }, + { + "type": "integer", + "format": "int64", + "description": "id of the attachment to edit", + "name": "attachment_id", + "in": "path", + "required": true + }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/EditIssueOption" + "$ref": "#/definitions/EditAttachmentOptions" } } ], "responses": { "201": { - "$ref": "#/responses/Issue" - }, - "403": { - "$ref": "#/responses/forbidden" + "$ref": "#/responses/Attachment" }, "404": { - "$ref": "#/responses/notFound" - }, - "412": { "$ref": "#/responses/error" } } @@ -13263,6 +13797,13 @@ "description": "Comment represents a comment on a commit or issue", "type": "object", "properties": { + "assets": { + "type": "array", + "items": { + "$ref": "#/definitions/Attachment" + }, + "x-go-name": "Attachments" + }, "body": { "type": "string", "x-go-name": "Body" @@ -15944,6 +16485,13 @@ "description": "Issue represents an issue in a repository", "type": "object", "properties": { + "assets": { + "type": "array", + "items": { + "$ref": "#/definitions/Attachment" + }, + "x-go-name": "Attachments" + }, "assignee": { "$ref": "#/definitions/User" },