From 3c59d31bc605bbefc6636e9b0a93e90ad2696ed9 Mon Sep 17 00:00:00 2001
From: KN4CK3R <admin@oldschoolhack.me>
Date: Fri, 9 Dec 2022 07:35:56 +0100
Subject: [PATCH] Add API management for issue/pull and comment attachments
 (#21783)

Close #14601
Fix #3690

Revive of #14601.
Updated to current code, cleanup and added more read/write checks.

Signed-off-by: Andrew Thornton <art27@cantab.net>
Signed-off-by: Andre Bruch <ab@andrebruch.com>
Co-authored-by: zeripath <art27@cantab.net>
Co-authored-by: 6543 <6543@obermui.de>
Co-authored-by: Norwin <git@nroo.de>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
---
 models/issues/comment.go                      |   2 +
 models/migrations/v1_10/v96.go                |  12 +-
 modules/convert/attachment.go                 |  30 +
 modules/convert/issue.go                      |  29 +-
 modules/convert/issue_comment.go              |  17 +-
 modules/convert/release.go                    |  19 +-
 modules/notification/webhook/webhook.go       |   5 +
 modules/structs/issue.go                      |  25 +-
 modules/structs/issue_comment.go              |  17 +-
 routers/api/v1/api.go                         |  25 +
 routers/api/v1/repo/issue_attachment.go       | 372 ++++++++++++
 routers/api/v1/repo/issue_comment.go          |   9 +
 .../api/v1/repo/issue_comment_attachment.go   | 383 ++++++++++++
 routers/api/v1/repo/release_attachment.go     |  13 +-
 routers/web/repo/attachment.go                |   8 +-
 routers/web/repo/issue.go                     |   5 +-
 services/attachment/attachment.go             |  11 +-
 services/release/release.go                   |  11 +-
 templates/swagger/v1_json.tmpl                | 548 ++++++++++++++++++
 .../api_comment_attachment_test.go            | 154 +++++
 .../integration/api_issue_attachment_test.go  | 143 +++++
 21 files changed, 1754 insertions(+), 84 deletions(-)
 create mode 100644 modules/convert/attachment.go
 create mode 100644 routers/api/v1/repo/issue_attachment.go
 create mode 100644 routers/api/v1/repo/issue_comment_attachment.go
 create mode 100644 tests/integration/api_comment_attachment_test.go
 create mode 100644 tests/integration/api_issue_attachment_test.go

diff --git a/models/issues/comment.go b/models/issues/comment.go
index f49c6e2c1f..2abe692a6f 100644
--- a/models/issues/comment.go
+++ b/models/issues/comment.go
@@ -875,6 +875,8 @@ func updateCommentInfos(ctx context.Context, opts *CreateCommentOptions, comment
 				return fmt.Errorf("update attachment [%d]: %w", attachments[i].ID, err)
 			}
 		}
+
+		comment.Attachments = attachments
 	case CommentTypeReopen, CommentTypeClose:
 		if err = repo_model.UpdateRepoIssueNumbers(ctx, opts.Issue.RepoID, opts.Issue.IsPull, true); err != nil {
 			return err
diff --git a/models/migrations/v1_10/v96.go b/models/migrations/v1_10/v96.go
index 2abd260be4..422defe838 100644
--- a/models/migrations/v1_10/v96.go
+++ b/models/migrations/v1_10/v96.go
@@ -30,19 +30,19 @@ func DeleteOrphanedAttachments(x *xorm.Engine) error {
 	}
 
 	for {
-		attachements := make([]Attachment, 0, limit)
+		attachments := make([]Attachment, 0, limit)
 		if err := sess.Where("`issue_id` = 0 and (`release_id` = 0 or `release_id` not in (select `id` from `release`))").
 			Cols("id, uuid").Limit(limit).
 			Asc("id").
-			Find(&attachements); err != nil {
+			Find(&attachments); err != nil {
 			return err
 		}
-		if len(attachements) == 0 {
+		if len(attachments) == 0 {
 			return nil
 		}
 
 		ids := make([]int64, 0, limit)
-		for _, attachment := range attachements {
+		for _, attachment := range attachments {
 			ids = append(ids, attachment.ID)
 		}
 		if len(ids) > 0 {
@@ -51,13 +51,13 @@ func DeleteOrphanedAttachments(x *xorm.Engine) error {
 			}
 		}
 
-		for _, attachment := range attachements {
+		for _, attachment := range attachments {
 			uuid := attachment.UUID
 			if err := util.RemoveAll(filepath.Join(setting.Attachment.Path, uuid[0:1], uuid[1:2], uuid)); err != nil {
 				return err
 			}
 		}
-		if len(attachements) < limit {
+		if len(attachments) < limit {
 			return nil
 		}
 	}
diff --git a/modules/convert/attachment.go b/modules/convert/attachment.go
new file mode 100644
index 0000000000..ddba181a12
--- /dev/null
+++ b/modules/convert/attachment.go
@@ -0,0 +1,30 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+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(),
+	}
+}
+
+func ToAttachments(attachments []*repo_model.Attachment) []*api.Attachment {
+	converted := make([]*api.Attachment, 0, len(attachments))
+	for _, attachment := range attachments {
+		converted = append(converted, ToAttachment(attachment))
+	}
+	return converted
+}
diff --git a/modules/convert/issue.go b/modules/convert/issue.go
index 3bc1006507..f3af03ed94 100644
--- a/modules/convert/issue.go
+++ b/modules/convert/issue.go
@@ -37,20 +37,21 @@ func ToAPIIssue(ctx context.Context, issue *issues_model.Issue) *api.Issue {
 	}
 
 	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: ToAttachments(issue.Attachments),
+		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 c4fed6b8a1..983354438a 100644
--- a/modules/convert/issue_comment.go
+++ b/modules/convert/issue_comment.go
@@ -16,14 +16,15 @@ import (
 // ToComment converts a issues_model.Comment to the api.Comment format
 func ToComment(c *issues_model.Comment) *api.Comment {
 	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: ToAttachments(c.Attachments),
+		Created:     c.CreatedUnix.AsTime(),
+		Updated:     c.UpdatedUnix.AsTime(),
 	}
 }
 
diff --git a/modules/convert/release.go b/modules/convert/release.go
index 95c6d03ab1..3afa53c03f 100644
--- a/modules/convert/release.go
+++ b/modules/convert/release.go
@@ -10,10 +10,6 @@ import (
 
 // ToRelease convert a repo_model.Release to api.Release
 func ToRelease(r *repo_model.Release) *api.Release {
-	assets := make([]*api.Attachment, 0)
-	for _, att := range r.Attachments {
-		assets = append(assets, ToReleaseAttachment(att))
-	}
 	return &api.Release{
 		ID:           r.ID,
 		TagName:      r.TagName,
@@ -29,19 +25,6 @@ func ToRelease(r *repo_model.Release) *api.Release {
 		CreatedAt:    r.CreatedUnix.AsTime(),
 		PublishedAt:  r.CreatedUnix.AsTime(),
 		Publisher:    ToUser(r.Publisher, nil),
-		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(),
+		Attachments:  ToAttachments(r.Attachments),
 	}
 }
diff --git a/modules/notification/webhook/webhook.go b/modules/notification/webhook/webhook.go
index 6334583058..cf056f54c1 100644
--- a/modules/notification/webhook/webhook.go
+++ b/modules/notification/webhook/webhook.go
@@ -314,6 +314,11 @@ func (m *webhookNotifier) NotifyNewPullRequest(ctx context.Context, pull *issues
 }
 
 func (m *webhookNotifier) NotifyIssueChangeContent(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, oldContent string) {
+	if err := issue.LoadRepo(ctx); err != nil {
+		log.Error("LoadRepo: %v", err)
+		return
+	}
+
 	mode, _ := access_model.AccessLevel(ctx, issue.Poster, issue.Repo)
 	var err error
 	if issue.IsPull {
diff --git a/modules/structs/issue.go b/modules/structs/issue.go
index 00166b7a07..48e4e0e7e3 100644
--- a/modules/structs/issue.go
+++ b/modules/structs/issue.go
@@ -41,18 +41,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 4a1085ba50..9e8f5c4bf3 100644
--- a/modules/structs/issue_comment.go
+++ b/modules/structs/issue_comment.go
@@ -9,14 +9,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 3f5cf431f8..14b168c242 100644
--- a/routers/api/v1/api.go
+++ b/routers/api/v1/api.go
@@ -567,6 +567,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)
@@ -892,6 +899,15 @@ func Routes(ctx gocontext.Context) *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() {
@@ -935,6 +951,15 @@ func Routes(ctx gocontext.Context) *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 0000000000..4cf108b413
--- /dev/null
+++ b/routers/api/v1/repo/issue_attachment.go
@@ -0,0 +1,372 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repo
+
+import (
+	"net/http"
+
+	issues_model "code.gitea.io/gitea/models/issues"
+	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"
+)
+
+// 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"
+
+	issue := getIssueFromContext(ctx)
+	if issue == nil {
+		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 := getIssueFromContext(ctx)
+	if issue == nil {
+		return
+	}
+
+	if err := issue.LoadAttributes(ctx); err != nil {
+		ctx.Error(http.StatusInternalServerError, "LoadAttributes", err)
+		return
+	}
+
+	ctx.JSON(http.StatusOK, convert.ToAPIIssue(ctx, 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"
+
+	issue := getIssueFromContext(ctx)
+	if issue == nil {
+		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, "FormFile", err)
+		return
+	}
+	defer file.Close()
+
+	filename := header.Filename
+	if query := ctx.FormString("name"); query != "" {
+		filename = query
+	}
+
+	attachment, 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, attachment)
+
+	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(attachment))
+}
+
+// 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"
+
+	attachment := getIssueAttachmentSafeWrite(ctx)
+	if attachment == nil {
+		return
+	}
+
+	// do changes to attachment. only meaningful change is name.
+	form := web.GetForm(ctx).(*api.EditAttachmentOptions)
+	if form.Name != "" {
+		attachment.Name = form.Name
+	}
+
+	if err := repo_model.UpdateAttachment(ctx, attachment); err != nil {
+		ctx.Error(http.StatusInternalServerError, "UpdateAttachment", err)
+	}
+
+	ctx.JSON(http.StatusCreated, convert.ToAttachment(attachment))
+}
+
+// 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"
+
+	attachment := getIssueAttachmentSafeWrite(ctx)
+	if attachment == nil {
+		return
+	}
+
+	if err := repo_model.DeleteAttachment(attachment, true); err != nil {
+		ctx.Error(http.StatusInternalServerError, "DeleteAttachment", err)
+		return
+	}
+
+	ctx.Status(http.StatusNoContent)
+}
+
+func getIssueFromContext(ctx *context.APIContext) *issues_model.Issue {
+	issue, err := issues_model.GetIssueByIndex(ctx.Repo.Repository.ID, ctx.ParamsInt64("index"))
+	if err != nil {
+		ctx.NotFoundOrServerError("GetIssueByIndex", issues_model.IsErrIssueNotExist, err)
+		return nil
+	}
+
+	issue.Repo = ctx.Repo.Repository
+
+	return issue
+}
+
+func getIssueAttachmentSafeWrite(ctx *context.APIContext) *repo_model.Attachment {
+	issue := getIssueFromContext(ctx)
+	if issue == nil {
+		return nil
+	}
+
+	if !canUserWriteIssueAttachment(ctx, issue) {
+		return nil
+	}
+
+	return getIssueAttachmentSafeRead(ctx, issue)
+}
+
+func getIssueAttachmentSafeRead(ctx *context.APIContext, issue *issues_model.Issue) *repo_model.Attachment {
+	attachment, err := repo_model.GetAttachmentByID(ctx, ctx.ParamsInt64("asset"))
+	if err != nil {
+		ctx.NotFoundOrServerError("GetAttachmentByID", repo_model.IsErrAttachmentNotExist, err)
+		return nil
+	}
+	if !attachmentBelongsToRepoOrIssue(ctx, attachment, issue) {
+		return nil
+	}
+	return attachment
+}
+
+func canUserWriteIssueAttachment(ctx *context.APIContext, issue *issues_model.Issue) bool {
+	canEditIssue := ctx.IsSigned && (ctx.Doer.ID == issue.PosterID || ctx.IsUserRepoAdmin() || ctx.IsUserSiteAdmin()) && ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull)
+	if !canEditIssue {
+		ctx.Error(http.StatusForbidden, "", "user should have permission to write issue")
+		return false
+	}
+
+	return true
+}
+
+func attachmentBelongsToRepoOrIssue(ctx *context.APIContext, attachment *repo_model.Attachment, issue *issues_model.Issue) bool {
+	if attachment.RepoID != ctx.Repo.Repository.ID {
+		log.Debug("Requested attachment[%d] does not belong to repo[%-v].", attachment.ID, ctx.Repo.Repository)
+		ctx.NotFound("no such attachment in repo")
+		return false
+	}
+	if attachment.IssueID == 0 {
+		log.Debug("Requested attachment[%d] is not in an issue.", attachment.ID)
+		ctx.NotFound("no such attachment in issue")
+		return false
+	} else if issue != nil && attachment.IssueID != issue.ID {
+		log.Debug("Requested attachment[%d] does not belong to issue[%d, #%d].", attachment.ID, issue.ID, issue.Index)
+		ctx.NotFound("no such attachment in issue")
+		return false
+	}
+	return true
+}
diff --git a/routers/api/v1/repo/issue_comment.go b/routers/api/v1/repo/issue_comment.go
index 120c1d88a0..a584a7a174 100644
--- a/routers/api/v1/repo/issue_comment.go
+++ b/routers/api/v1/repo/issue_comment.go
@@ -95,6 +95,11 @@ func ListIssueComments(ctx *context.APIContext) {
 		return
 	}
 
+	if err := issues_model.CommentList(comments).LoadAttachments(ctx); err != nil {
+		ctx.Error(http.StatusInternalServerError, "LoadAttachments", err)
+		return
+	}
+
 	apiComments := make([]*api.Comment, len(comments))
 	for i, comment := range comments {
 		comment.Issue = issue
@@ -294,6 +299,10 @@ func ListRepoIssueComments(ctx *context.APIContext) {
 		ctx.Error(http.StatusInternalServerError, "LoadPosters", err)
 		return
 	}
+	if err := issues_model.CommentList(comments).LoadAttachments(ctx); err != nil {
+		ctx.Error(http.StatusInternalServerError, "LoadAttachments", err)
+		return
+	}
 	if _, err := issues_model.CommentList(comments).Issues().LoadRepositories(ctx); 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 0000000000..60ea8d1b83
--- /dev/null
+++ b/routers/api/v1/repo/issue_comment_attachment.go
@@ -0,0 +1,383 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repo
+
+import (
+	"net/http"
+
+	issues_model "code.gitea.io/gitea/models/issues"
+	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
+	}
+	attachment := getIssueCommentAttachmentSafeRead(ctx, comment)
+	if attachment == nil {
+		return
+	}
+	if attachment.CommentID != comment.ID {
+		log.Debug("User requested attachment[%d] is not in comment[%d].", attachment.ID, comment.ID)
+		ctx.NotFound("attachment not in comment")
+		return
+	}
+
+	ctx.JSON(http.StatusOK, convert.ToAttachment(attachment))
+}
+
+// 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(ctx); err != nil {
+		ctx.Error(http.StatusInternalServerError, "LoadAttachments", err)
+		return
+	}
+
+	ctx.JSON(http.StatusOK, convert.ToAttachments(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, "FormFile", err)
+		return
+	}
+	defer file.Close()
+
+	filename := header.Filename
+	if query := ctx.FormString("name"); query != "" {
+		filename = query
+	}
+
+	attachment, 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(ctx); err != nil {
+		ctx.Error(http.StatusInternalServerError, "LoadAttachments", err)
+		return
+	}
+
+	if err = comment_service.UpdateComment(ctx, comment, ctx.Doer, comment.Content); err != nil {
+		ctx.ServerError("UpdateComment", err)
+		return
+	}
+
+	ctx.JSON(http.StatusCreated, convert.ToAttachment(attachment))
+}
+
+// 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.UpdateAttachment(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) *issues_model.Comment {
+	comment, err := issues_model.GetCommentByID(ctx, ctx.ParamsInt64("id"))
+	if err != nil {
+		ctx.NotFoundOrServerError("GetCommentByID", issues_model.IsErrCommentNotExist, err)
+		return nil
+	}
+	if err := comment.LoadIssue(ctx); 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
+	}
+
+	comment.Issue.Repo = ctx.Repo.Repository
+
+	return comment
+}
+
+func getIssueCommentAttachmentSafeWrite(ctx *context.APIContext) *repo_model.Attachment {
+	comment := getIssueCommentSafe(ctx)
+	if comment == nil {
+		return nil
+	}
+	if !canUserWriteIssueCommentAttachment(ctx, comment) {
+		return nil
+	}
+	return getIssueCommentAttachmentSafeRead(ctx, comment)
+}
+
+func canUserWriteIssueCommentAttachment(ctx *context.APIContext, comment *issues_model.Comment) bool {
+	canEditComment := ctx.IsSigned && (ctx.Doer.ID == comment.PosterID || ctx.IsUserRepoAdmin() || ctx.IsUserSiteAdmin()) && ctx.Repo.CanWriteIssuesOrPulls(comment.Issue.IsPull)
+	if !canEditComment {
+		ctx.Error(http.StatusForbidden, "", "user should have permission to edit comment")
+		return false
+	}
+
+	return true
+}
+
+func getIssueCommentAttachmentSafeRead(ctx *context.APIContext, comment *issues_model.Comment) *repo_model.Attachment {
+	attachment, err := repo_model.GetAttachmentByID(ctx, ctx.ParamsInt64("asset"))
+	if err != nil {
+		ctx.NotFoundOrServerError("GetAttachmentByID", repo_model.IsErrAttachmentNotExist, err)
+		return nil
+	}
+	if !attachmentBelongsToRepoOrComment(ctx, attachment, comment) {
+		return nil
+	}
+	return attachment
+}
+
+func attachmentBelongsToRepoOrComment(ctx *context.APIContext, attachment *repo_model.Attachment, comment *issues_model.Comment) bool {
+	if attachment.RepoID != ctx.Repo.Repository.ID {
+		log.Debug("Requested attachment[%d] does not belong to repo[%-v].", attachment.ID, ctx.Repo.Repository)
+		ctx.NotFound("no such attachment in repo")
+		return false
+	}
+	if attachment.IssueID == 0 || attachment.CommentID == 0 {
+		log.Debug("Requested attachment[%d] is not in a comment.", attachment.ID)
+		ctx.NotFound("no such attachment in comment")
+		return false
+	}
+	if comment != nil && attachment.CommentID != comment.ID {
+		log.Debug("Requested attachment[%d] does not belong to comment[%d].", attachment.ID, comment.ID)
+		ctx.NotFound("no such attachment in comment")
+		return false
+	}
+	return true
+}
diff --git a/routers/api/v1/repo/release_attachment.go b/routers/api/v1/repo/release_attachment.go
index 88632b4637..e7dbb42c74 100644
--- a/routers/api/v1/repo/release_attachment.go
+++ b/routers/api/v1/repo/release_attachment.go
@@ -68,7 +68,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
@@ -194,7 +194,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)
@@ -204,7 +209,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
@@ -274,7 +279,7 @@ func EditReleaseAttachment(ctx *context.APIContext) {
 	if err := repo_model.UpdateAttachment(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 656e40f878..589632ad6e 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())
@@ -82,7 +86,7 @@ func DeleteAttachment(ctx *context.Context) {
 	})
 }
 
-// GetAttachment serve attachements
+// GetAttachment serve attachments
 func GetAttachment(ctx *context.Context) {
 	attach, err := repo_model.GetAttachmentByUUID(ctx, ctx.Params(":uuid"))
 	if err != nil {
diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go
index a531e83206..b11cc58e41 100644
--- a/routers/web/repo/issue.go
+++ b/routers/web/repo/issue.go
@@ -2749,6 +2749,7 @@ func UpdateCommentContent(ctx *context.Context) {
 		})
 		return
 	}
+
 	if err = comment_service.UpdateComment(ctx, comment, ctx.Doer, oldContent); err != nil {
 		ctx.ServerError("UpdateComment", err)
 		return
@@ -3050,7 +3051,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)
 }
@@ -3069,7 +3070,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 522acd00a3..7fdacc6aae 100644
--- a/services/attachment/attachment.go
+++ b/services/attachment/attachment.go
@@ -39,19 +39,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/services/release/release.go b/services/release/release.go
index 1d599fcda1..13042cd3ac 100644
--- a/services/release/release.go
+++ b/services/release/release.go
@@ -21,6 +21,7 @@ import (
 	"code.gitea.io/gitea/modules/repository"
 	"code.gitea.io/gitea/modules/storage"
 	"code.gitea.io/gitea/modules/timeutil"
+	"code.gitea.io/gitea/modules/util"
 )
 
 func createTag(ctx context.Context, gitRepo *git.Repository, rel *repo_model.Release, msg string) (bool, error) {
@@ -218,7 +219,10 @@ func UpdateRelease(doer *user_model.User, gitRepo *git.Repository, rel *repo_mod
 		}
 		for _, attach := range attachments {
 			if attach.ReleaseID != rel.ID {
-				return errors.New("delete attachement of release permission denied")
+				return util.SilentWrap{
+					Message: "delete attachment of release permission denied",
+					Err:     util.ErrPermissionDenied,
+				}
 			}
 			deletedUUIDs.Add(attach.UUID)
 		}
@@ -240,7 +244,10 @@ func UpdateRelease(doer *user_model.User, gitRepo *git.Repository, rel *repo_mod
 		}
 		for _, attach := range attachments {
 			if attach.ReleaseID != rel.ID {
-				return errors.New("update attachement of release permission denied")
+				return util.SilentWrap{
+					Message: "update attachment of release permission denied",
+					Err:     util.ErrPermissionDenied,
+				}
 			}
 		}
 
diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl
index ddafc146a1..c86c6744de 100644
--- a/templates/swagger/v1_json.tmpl
+++ b/templates/swagger/v1_json.tmpl
@@ -5095,6 +5095,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": [
@@ -5393,6 +5660,273 @@
         }
       }
     },
+    "/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"
+          }
+        }
+      },
+      "post": {
+        "consumes": [
+          "multipart/form-data"
+        ],
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "issue"
+        ],
+        "summary": "Create an issue attachment",
+        "operationId": "issueCreateIssueAttachment",
+        "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
+          },
+          {
+            "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/{index}/assets/{attachment_id}": {
+      "get": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "issue"
+        ],
+        "summary": "Get an issue attachment",
+        "operationId": "issueGetIssueAttachment",
+        "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
+          },
+          {
+            "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 an issue attachment",
+        "operationId": "issueDeleteIssueAttachment",
+        "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
+          },
+          {
+            "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 an issue attachment",
+        "operationId": "issueEditIssueAttachment",
+        "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
+          },
+          {
+            "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/{index}/comments": {
       "get": {
         "produces": [
@@ -13882,6 +14416,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"
@@ -16634,6 +17175,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"
         },
diff --git a/tests/integration/api_comment_attachment_test.go b/tests/integration/api_comment_attachment_test.go
new file mode 100644
index 0000000000..22bf502ef6
--- /dev/null
+++ b/tests/integration/api_comment_attachment_test.go
@@ -0,0 +1,154 @@
+// 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 integration
+
+import (
+	"bytes"
+	"fmt"
+	"io"
+	"mime/multipart"
+	"net/http"
+	"testing"
+
+	"code.gitea.io/gitea/models/db"
+	issues_model "code.gitea.io/gitea/models/issues"
+	repo_model "code.gitea.io/gitea/models/repo"
+	"code.gitea.io/gitea/models/unittest"
+	user_model "code.gitea.io/gitea/models/user"
+	"code.gitea.io/gitea/modules/convert"
+	api "code.gitea.io/gitea/modules/structs"
+	"code.gitea.io/gitea/tests"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestAPIGetCommentAttachment(t *testing.T) {
+	defer tests.PrepareTestEnv(t)()
+
+	comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 2})
+	assert.NoError(t, comment.LoadIssue(db.DefaultContext))
+	assert.NoError(t, comment.LoadAttachments(db.DefaultContext))
+	attachment := unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: comment.Attachments[0].ID})
+	repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: comment.Issue.RepoID})
+	repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+	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)
+	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 tests.PrepareTestEnv(t)()
+
+	comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 2})
+	issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: comment.IssueID})
+	repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID})
+	repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+	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 := unittest.GetCount(t, &repo_model.Attachment{CommentID: comment.ID})
+	assert.EqualValues(t, expectedCount, len(apiAttachments))
+
+	unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: apiAttachments[0].ID, CommentID: comment.ID})
+}
+
+func TestAPICreateCommentAttachment(t *testing.T) {
+	defer tests.PrepareTestEnv(t)()
+
+	comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 2})
+	issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: comment.IssueID})
+	repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID})
+	repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+	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)
+
+	unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: apiAttachment.ID, CommentID: comment.ID})
+}
+
+func TestAPIEditCommentAttachment(t *testing.T) {
+	defer tests.PrepareTestEnv(t)()
+
+	const newAttachmentName = "newAttachmentName"
+
+	attachment := unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: 6})
+	comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: attachment.CommentID})
+	issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: comment.IssueID})
+	repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID})
+	repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+	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)
+
+	unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: apiAttachment.ID, CommentID: comment.ID, Name: apiAttachment.Name})
+}
+
+func TestAPIDeleteCommentAttachment(t *testing.T) {
+	defer tests.PrepareTestEnv(t)()
+
+	attachment := unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: 6})
+	comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: attachment.CommentID})
+	issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: comment.IssueID})
+	repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID})
+	repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+	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)
+
+	unittest.AssertNotExistsBean(t, &repo_model.Attachment{ID: attachment.ID, CommentID: comment.ID})
+}
diff --git a/tests/integration/api_issue_attachment_test.go b/tests/integration/api_issue_attachment_test.go
new file mode 100644
index 0000000000..0558dda56a
--- /dev/null
+++ b/tests/integration/api_issue_attachment_test.go
@@ -0,0 +1,143 @@
+// 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 integration
+
+import (
+	"bytes"
+	"fmt"
+	"io"
+	"mime/multipart"
+	"net/http"
+	"testing"
+
+	issues_model "code.gitea.io/gitea/models/issues"
+	repo_model "code.gitea.io/gitea/models/repo"
+	"code.gitea.io/gitea/models/unittest"
+	user_model "code.gitea.io/gitea/models/user"
+	api "code.gitea.io/gitea/modules/structs"
+	"code.gitea.io/gitea/tests"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestAPIGetIssueAttachment(t *testing.T) {
+	defer tests.PrepareTestEnv(t)()
+
+	attachment := unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: 1})
+	repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: attachment.RepoID})
+	issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: attachment.IssueID})
+	repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+	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)
+
+	unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: apiAttachment.ID, IssueID: issue.ID})
+}
+
+func TestAPIListIssueAttachments(t *testing.T) {
+	defer tests.PrepareTestEnv(t)()
+
+	attachment := unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: 1})
+	repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: attachment.RepoID})
+	issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: attachment.IssueID})
+	repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+	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)
+
+	unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: (*apiAttachment)[0].ID, IssueID: issue.ID})
+}
+
+func TestAPICreateIssueAttachment(t *testing.T) {
+	defer tests.PrepareTestEnv(t)()
+
+	repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+	issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: repo.ID})
+	repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+	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)
+
+	unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: apiAttachment.ID, IssueID: issue.ID})
+}
+
+func TestAPIEditIssueAttachment(t *testing.T) {
+	defer tests.PrepareTestEnv(t)()
+
+	const newAttachmentName = "newAttachmentName"
+
+	attachment := unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: 1})
+	repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: attachment.RepoID})
+	issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: attachment.IssueID})
+	repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+	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)
+
+	unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: apiAttachment.ID, IssueID: issue.ID, Name: apiAttachment.Name})
+}
+
+func TestAPIDeleteIssueAttachment(t *testing.T) {
+	defer tests.PrepareTestEnv(t)()
+
+	attachment := unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: 1})
+	repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: attachment.RepoID})
+	issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: attachment.IssueID})
+	repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+	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)
+
+	unittest.AssertNotExistsBean(t, &repo_model.Attachment{ID: attachment.ID, IssueID: issue.ID})
+}