From 8c909820a9fd1697bb690ec0451c4ead97b51505 Mon Sep 17 00:00:00 2001
From: blueworrybear <blueworrybear@gmail.com>
Date: Tue, 15 Oct 2019 20:19:32 +0800
Subject: [PATCH] Enable Uploading/Removing Attachments When Editing an
 Issue/Comment (#8426)

---
 models/issue.go                               |  20 +++
 models/issue_comment.go                       |  21 ++++
 modules/util/compare.go                       |  10 ++
 public/js/index.js                            | 119 +++++++++++++++---
 routers/repo/attachment.go                    |  22 ++++
 routers/repo/issue.go                         | 110 +++++++++++++++-
 routers/routes/routes.go                      |   7 +-
 templates/repo/issue/view_content.tmpl        |  25 ++--
 .../repo/issue/view_content/attachments.tmpl  |   9 ++
 .../repo/issue/view_content/comments.tmpl     |  12 +-
 10 files changed, 316 insertions(+), 39 deletions(-)
 create mode 100644 templates/repo/issue/view_content/attachments.tmpl

diff --git a/models/issue.go b/models/issue.go
index 90925f92f..6503a0618 100644
--- a/models/issue.go
+++ b/models/issue.go
@@ -855,6 +855,26 @@ func AddDeletePRBranchComment(doer *User, repo *Repository, issueID int64, branc
 	return sess.Commit()
 }
 
+// UpdateAttachments update attachments by UUIDs for the issue
+func (issue *Issue) UpdateAttachments(uuids []string) (err error) {
+	sess := x.NewSession()
+	defer sess.Close()
+	if err = sess.Begin(); err != nil {
+		return err
+	}
+	attachments, err := getAttachmentsByUUIDs(sess, uuids)
+	if err != nil {
+		return fmt.Errorf("getAttachmentsByUUIDs [uuids: %v]: %v", uuids, err)
+	}
+	for i := 0; i < len(attachments); i++ {
+		attachments[i].IssueID = issue.ID
+		if err := updateAttachment(sess, attachments[i]); err != nil {
+			return fmt.Errorf("update attachment [id: %d]: %v", attachments[i].ID, err)
+		}
+	}
+	return sess.Commit()
+}
+
 // ChangeContent changes issue content, as the given user.
 func (issue *Issue) ChangeContent(doer *User, content string) (err error) {
 	oldContent := issue.Content
diff --git a/models/issue_comment.go b/models/issue_comment.go
index 3a090c3b1..ccf239d60 100644
--- a/models/issue_comment.go
+++ b/models/issue_comment.go
@@ -357,6 +357,27 @@ func (c *Comment) LoadAttachments() error {
 	return nil
 }
 
+// UpdateAttachments update attachments by UUIDs for the comment
+func (c *Comment) UpdateAttachments(uuids []string) error {
+	sess := x.NewSession()
+	defer sess.Close()
+	if err := sess.Begin(); err != nil {
+		return err
+	}
+	attachments, err := getAttachmentsByUUIDs(sess, uuids)
+	if err != nil {
+		return fmt.Errorf("getAttachmentsByUUIDs [uuids: %v]: %v", uuids, err)
+	}
+	for i := 0; i < len(attachments); i++ {
+		attachments[i].IssueID = c.IssueID
+		attachments[i].CommentID = c.ID
+		if err := updateAttachment(sess, attachments[i]); err != nil {
+			return fmt.Errorf("update attachment [id: %d]: %v", attachments[i].ID, err)
+		}
+	}
+	return sess.Commit()
+}
+
 // LoadAssigneeUser if comment.Type is CommentTypeAssignees, then load assignees
 func (c *Comment) LoadAssigneeUser() error {
 	var err error
diff --git a/modules/util/compare.go b/modules/util/compare.go
index c61e7965a..f1d1e5718 100644
--- a/modules/util/compare.go
+++ b/modules/util/compare.go
@@ -35,6 +35,16 @@ func ExistsInSlice(target string, slice []string) bool {
 	return i < len(slice)
 }
 
+// IsStringInSlice sequential searches if string exists in slice.
+func IsStringInSlice(target string, slice []string) bool {
+	for i := 0; i < len(slice); i++ {
+		if slice[i] == target {
+			return true
+		}
+	}
+	return false
+}
+
 // IsEqualSlice returns true if slices are equal.
 func IsEqualSlice(target []string, source []string) bool {
 	if len(target) != len(source) {
diff --git a/public/js/index.js b/public/js/index.js
index 3b15ad8f1..11b2e75f2 100644
--- a/public/js/index.js
+++ b/public/js/index.js
@@ -865,6 +865,73 @@ function initRepository() {
                 issuesTribute.attach($textarea.get());
                 emojiTribute.attach($textarea.get());
 
+                const $dropzone = $editContentZone.find('.dropzone');
+                $dropzone.data("saved", false);
+                const $files = $editContentZone.find('.comment-files');
+                if ($dropzone.length > 0) {
+                    const filenameDict = {};
+                    $dropzone.dropzone({
+                        url: $dropzone.data('upload-url'),
+                        headers: {"X-Csrf-Token": csrf},
+                        maxFiles: $dropzone.data('max-file'),
+                        maxFilesize: $dropzone.data('max-size'),
+                        acceptedFiles: ($dropzone.data('accepts') === '*/*') ? null : $dropzone.data('accepts'),
+                        addRemoveLinks: true,
+                        dictDefaultMessage: $dropzone.data('default-message'),
+                        dictInvalidFileType: $dropzone.data('invalid-input-type'),
+                        dictFileTooBig: $dropzone.data('file-too-big'),
+                        dictRemoveFile: $dropzone.data('remove-file'),
+                        init: function () {
+                            this.on("success", function (file, data) {
+                                filenameDict[file.name] = {
+                                    "uuid": data.uuid,
+                                    "submitted": false
+                                }
+                                const input = $('<input id="' + data.uuid + '" name="files" type="hidden">').val(data.uuid);
+                                $files.append(input);
+                            });
+                            this.on("removedfile", function (file) {
+                                if (!(file.name in filenameDict)) {
+                                    return;
+                                }
+                                $('#' + filenameDict[file.name].uuid).remove();
+                                if ($dropzone.data('remove-url') && $dropzone.data('csrf') && !filenameDict[file.name].submitted) {
+                                    $.post($dropzone.data('remove-url'), {
+                                        file: filenameDict[file.name].uuid,
+                                        _csrf: $dropzone.data('csrf')
+                                    });
+                                }
+                            });
+                            this.on("submit", function () {
+                                $.each(filenameDict, function(name){
+                                    filenameDict[name].submitted = true;
+                                });
+                            });
+                            this.on("reload", function (){
+                                $.getJSON($editContentZone.data('attachment-url'), function(data){
+                                    const drop = $dropzone.get(0).dropzone;
+                                    drop.removeAllFiles(true);
+                                    $files.empty();
+                                    $.each(data, function(){
+                                        const imgSrc =  $dropzone.data('upload-url') + "/" + this.uuid;
+                                        drop.emit("addedfile", this);
+                                        drop.emit("thumbnail", this, imgSrc);
+                                        drop.emit("complete", this);
+                                        drop.files.push(this);
+                                        filenameDict[this.name] = {
+                                            "submitted": true,
+                                            "uuid": this.uuid
+                                        }
+                                        $dropzone.find("img[src='" + imgSrc + "']").css("max-width", "100%");
+                                        const input = $('<input id="' + this.uuid + '" name="files" type="hidden">').val(this.uuid);
+                                        $files.append(input);
+                                    });
+                                });
+                            });
+                        }
+                    });
+                    $dropzone.get(0).dropzone.emit("reload");
+                }
                 // Give new write/preview data-tab name to distinguish from others
                 const $editContentForm = $editContentZone.find('.ui.comment.form');
                 const $tabMenu = $editContentForm.find('.tabular.menu');
@@ -880,27 +947,49 @@ function initRepository() {
                 $editContentZone.find('.cancel.button').click(function () {
                     $renderContent.show();
                     $editContentZone.hide();
+                    $dropzone.get(0).dropzone.emit("reload");
                 });
                 $editContentZone.find('.save.button').click(function () {
                     $renderContent.show();
                     $editContentZone.hide();
-
+                    const $attachments = $files.find("[name=files]").map(function(){
+                        return $(this).val();
+                    }).get();
                     $.post($editContentZone.data('update-url'), {
-                            "_csrf": csrf,
-                            "content": $textarea.val(),
-                            "context": $editContentZone.data('context')
-                        },
-                        function (data) {
-                            if (data.length == 0) {
-                                $renderContent.html($('#no-content').html());
-                            } else {
-                                $renderContent.html(data.content);
-                                emojify.run($renderContent[0]);
-                                $('pre code', $renderContent[0]).each(function () {
-                                    hljs.highlightBlock(this);
-                                });
+                        "_csrf": csrf,
+                        "content": $textarea.val(),
+                        "context": $editContentZone.data('context'),
+                        "files": $attachments
+                    },
+                    function (data) {
+                        if (data.length == 0) {
+                            $renderContent.html($('#no-content').html());
+                        } else {
+                            $renderContent.html(data.content);
+                            emojify.run($renderContent[0]);
+                            $('pre code', $renderContent[0]).each(function () {
+                                hljs.highlightBlock(this);
+                            });
+                        }
+                        const $content = $segment.parent();
+                        if(!$content.find(".ui.small.images").length){
+                            if(data.attachments != ""){
+                                $content.append(
+                                '<div class="ui bottom attached segment">' +
+                                '    <div class="ui small images">' +
+                                '    </div>' +
+                                '</div>'
+                                );
+                                $content.find(".ui.small.images").html(data.attachments);
                             }
-                        });
+                        } else if (data.attachments == "") {
+                            $content.find(".ui.small.images").parent().remove();
+                        } else {
+                            $content.find(".ui.small.images").html(data.attachments);
+                        }
+                        $dropzone.get(0).dropzone.emit("submit");
+                        $dropzone.get(0).dropzone.emit("reload");
+                    });
                 });
             } else {
                 $textarea = $segment.find('textarea');
diff --git a/routers/repo/attachment.go b/routers/repo/attachment.go
index a07a2a8ac..0d496230e 100644
--- a/routers/repo/attachment.go
+++ b/routers/repo/attachment.go
@@ -63,3 +63,25 @@ func UploadAttachment(ctx *context.Context) {
 		"uuid": attach.UUID,
 	})
 }
+
+// DeleteAttachment response for deleting issue's attachment
+func DeleteAttachment(ctx *context.Context) {
+	file := ctx.Query("file")
+	attach, err := models.GetAttachmentByUUID(file)
+	if !ctx.IsSigned || (ctx.User.ID != attach.UploaderID) {
+		ctx.Error(403)
+		return
+	}
+	if err != nil {
+		ctx.Error(400, err.Error())
+		return
+	}
+	err = models.DeleteAttachment(attach, true)
+	if err != nil {
+		ctx.Error(500, fmt.Sprintf("DeleteAttachment: %v", err))
+		return
+	}
+	ctx.JSON(200, map[string]string{
+		"uuid": attach.UUID,
+	})
+}
diff --git a/routers/repo/issue.go b/routers/repo/issue.go
index 16a049c7a..dee2c6e69 100644
--- a/routers/repo/issue.go
+++ b/routers/repo/issue.go
@@ -34,6 +34,8 @@ import (
 )
 
 const (
+	tplAttachment base.TplName = "repo/issue/view_content/attachments"
+
 	tplIssues    base.TplName = "repo/issue/list"
 	tplIssueNew  base.TplName = "repo/issue/new"
 	tplIssueView base.TplName = "repo/issue/view"
@@ -1074,8 +1076,14 @@ func UpdateIssueContent(ctx *context.Context) {
 		return
 	}
 
+	files := ctx.QueryStrings("files[]")
+	if err := updateAttachments(issue, files); err != nil {
+		ctx.ServerError("UpdateAttachments", err)
+	}
+
 	ctx.JSON(200, map[string]interface{}{
-		"content": string(markdown.Render([]byte(issue.Content), ctx.Query("context"), ctx.Repo.Repository.ComposeMetas())),
+		"content":     string(markdown.Render([]byte(issue.Content), ctx.Query("context"), ctx.Repo.Repository.ComposeMetas())),
+		"attachments": attachmentsHTML(ctx, issue.Attachments),
 	})
 }
 
@@ -1325,6 +1333,13 @@ func UpdateCommentContent(ctx *context.Context) {
 		return
 	}
 
+	if comment.Type == models.CommentTypeComment {
+		if err := comment.LoadAttachments(); err != nil {
+			ctx.ServerError("LoadAttachments", err)
+			return
+		}
+	}
+
 	if !ctx.IsSigned || (ctx.User.ID != comment.PosterID && !ctx.Repo.CanWriteIssuesOrPulls(comment.Issue.IsPull)) {
 		ctx.Error(403)
 		return
@@ -1346,10 +1361,16 @@ func UpdateCommentContent(ctx *context.Context) {
 		return
 	}
 
+	files := ctx.QueryStrings("files[]")
+	if err := updateAttachments(comment, files); err != nil {
+		ctx.ServerError("UpdateAttachments", err)
+	}
+
 	notification.NotifyUpdateComment(ctx.User, comment, oldContent)
 
 	ctx.JSON(200, map[string]interface{}{
-		"content": string(markdown.Render([]byte(comment.Content), ctx.Query("context"), ctx.Repo.Repository.ComposeMetas())),
+		"content":     string(markdown.Render([]byte(comment.Content), ctx.Query("context"), ctx.Repo.Repository.ComposeMetas())),
+		"attachments": attachmentsHTML(ctx, comment.Attachments),
 	})
 }
 
@@ -1603,3 +1624,88 @@ func filterXRefComments(ctx *context.Context, issue *models.Issue) error {
 	}
 	return nil
 }
+
+// GetIssueAttachments returns attachments for the issue
+func GetIssueAttachments(ctx *context.Context) {
+	issue := GetActionIssue(ctx)
+	var attachments = make([]*api.Attachment, len(issue.Attachments))
+	for i := 0; i < len(issue.Attachments); i++ {
+		attachments[i] = issue.Attachments[i].APIFormat()
+	}
+	ctx.JSON(200, attachments)
+}
+
+// GetCommentAttachments returns attachments for the comment
+func GetCommentAttachments(ctx *context.Context) {
+	comment, err := models.GetCommentByID(ctx.ParamsInt64(":id"))
+	if err != nil {
+		ctx.NotFoundOrServerError("GetCommentByID", models.IsErrCommentNotExist, err)
+		return
+	}
+	var attachments = make([]*api.Attachment, 0)
+	if comment.Type == models.CommentTypeComment {
+		if err := comment.LoadAttachments(); err != nil {
+			ctx.ServerError("LoadAttachments", err)
+			return
+		}
+		for i := 0; i < len(comment.Attachments); i++ {
+			attachments = append(attachments, comment.Attachments[i].APIFormat())
+		}
+	}
+	ctx.JSON(200, attachments)
+}
+
+func updateAttachments(item interface{}, files []string) error {
+	var attachments []*models.Attachment
+	switch content := item.(type) {
+	case *models.Issue:
+		attachments = content.Attachments
+	case *models.Comment:
+		attachments = content.Attachments
+	default:
+		return fmt.Errorf("Unknow Type")
+	}
+	for i := 0; i < len(attachments); i++ {
+		if util.IsStringInSlice(attachments[i].UUID, files) {
+			continue
+		}
+		if err := models.DeleteAttachment(attachments[i], true); err != nil {
+			return err
+		}
+	}
+	var err error
+	if len(files) > 0 {
+		switch content := item.(type) {
+		case *models.Issue:
+			err = content.UpdateAttachments(files)
+		case *models.Comment:
+			err = content.UpdateAttachments(files)
+		default:
+			return fmt.Errorf("Unknow Type")
+		}
+		if err != nil {
+			return err
+		}
+	}
+	switch content := item.(type) {
+	case *models.Issue:
+		content.Attachments, err = models.GetAttachmentsByIssueID(content.ID)
+	case *models.Comment:
+		content.Attachments, err = models.GetAttachmentsByCommentID(content.ID)
+	default:
+		return fmt.Errorf("Unknow Type")
+	}
+	return err
+}
+
+func attachmentsHTML(ctx *context.Context, attachments []*models.Attachment) string {
+	attachHTML, err := ctx.HTMLString(string(tplAttachment), map[string]interface{}{
+		"ctx":         ctx.Data,
+		"Attachments": attachments,
+	})
+	if err != nil {
+		ctx.ServerError("attachmentsHTML.HTMLString", err)
+		return ""
+	}
+	return attachHTML
+}
diff --git a/routers/routes/routes.go b/routers/routes/routes.go
index 0db0af43f..9572ea803 100644
--- a/routers/routes/routes.go
+++ b/routers/routes/routes.go
@@ -513,8 +513,9 @@ func RegisterRoutes(m *macaron.Macaron) {
 		})
 	}, ignSignIn)
 
-	m.Group("", func() {
-		m.Post("/attachments", repo.UploadAttachment)
+	m.Group("/attachments", func() {
+		m.Post("", repo.UploadAttachment)
+		m.Post("/delete", repo.DeleteAttachment)
 	}, reqSignIn)
 
 	m.Group("/:username", func() {
@@ -710,6 +711,7 @@ func RegisterRoutes(m *macaron.Macaron) {
 				m.Post("/reactions/:action", bindIgnErr(auth.ReactionForm{}), repo.ChangeIssueReaction)
 				m.Post("/lock", reqRepoIssueWriter, bindIgnErr(auth.IssueLockForm{}), repo.LockIssue)
 				m.Post("/unlock", reqRepoIssueWriter, repo.UnlockIssue)
+				m.Get("/attachments", repo.GetIssueAttachments)
 			}, context.RepoMustNotBeArchived())
 
 			m.Post("/labels", reqRepoIssuesOrPullsWriter, repo.UpdateIssueLabel)
@@ -721,6 +723,7 @@ func RegisterRoutes(m *macaron.Macaron) {
 			m.Post("", repo.UpdateCommentContent)
 			m.Post("/delete", repo.DeleteComment)
 			m.Post("/reactions/:action", bindIgnErr(auth.ReactionForm{}), repo.ChangeCommentReaction)
+			m.Get("/attachments", repo.GetCommentAttachments)
 		}, context.RepoMustNotBeArchived())
 		m.Group("/labels", func() {
 			m.Post("/new", bindIgnErr(auth.CreateLabelForm{}), repo.NewLabel)
diff --git a/templates/repo/issue/view_content.tmpl b/templates/repo/issue/view_content.tmpl
index acabe3478..29d48d708 100644
--- a/templates/repo/issue/view_content.tmpl
+++ b/templates/repo/issue/view_content.tmpl
@@ -46,7 +46,7 @@
 							{{end}}
 						</div>
 						<div class="raw-content hide">{{.Issue.Content}}</div>
-						<div class="edit-content-zone hide" data-write="issue-{{.Issue.ID}}-write" data-preview="issue-{{.Issue.ID}}-preview" data-update-url="{{$.RepoLink}}/issues/{{.Issue.Index}}/content" data-context="{{.RepoLink}}"></div>
+						<div class="edit-content-zone hide" data-write="issue-{{.Issue.ID}}-write" data-preview="issue-{{.Issue.ID}}-preview" data-update-url="{{$.RepoLink}}/issues/{{.Issue.Index}}/content" data-context="{{.RepoLink}}" data-attachment-url="{{$.RepoLink}}/issues/{{.Issue.Index}}/attachments" data-view-attachment-url="{{$.RepoLink}}/issues/{{.Issue.Index}}/view-attachments"></div>
 					</div>
 					{{$reactions := .Issue.Reactions.GroupByType}}
 					{{if $reactions}}
@@ -57,15 +57,7 @@
 					{{if .Issue.Attachments}}
 						<div class="ui bottom attached segment">
 							<div class="ui small images">
-								{{range .Issue.Attachments}}
-									<a target="_blank" rel="noopener noreferrer" href="{{AppSubUrl}}/attachments/{{.UUID}}">
-										{{if FilenameIsImage .Name}}
-											<img class="ui image" src="{{AppSubUrl}}/attachments/{{.UUID}}" title='{{$.i18n.Tr "repo.issues.attachment.open_tab" .Name}}'>
-										{{else}}
-											<span class="ui image octicon octicon-desktop-download" title='{{$.i18n.Tr "repo.issues.attachment.download" .Name}}'></span>
-										{{end}}
-									</a>
-								{{end}}
+								{{template "repo/issue/view_content/attachments" Dict "ctx" $ "Attachments" .Issue.Attachments}}
 							</div>
 						</div>
 					{{end}}
@@ -182,6 +174,19 @@
 		<div class="ui bottom attached tab preview segment markdown">
 			{{$.i18n.Tr "loading"}}
 		</div>
+		{{if .IsAttachmentEnabled}}
+			<div class="comment-files"></div>
+			<div class="ui basic button dropzone" id="comment-dropzone"
+				data-upload-url="{{AppSubUrl}}/attachments"
+				data-remove-url="{{AppSubUrl}}/attachments/delete"
+				data-csrf="{{.CsrfToken}}" data-accepts="{{.AttachmentAllowedTypes}}"
+				data-max-file="{{.AttachmentMaxFiles}}" data-max-size="{{.AttachmentMaxSize}}"
+				data-default-message="{{.i18n.Tr "dropzone.default_message"}}"
+				data-invalid-input-type="{{.i18n.Tr "dropzone.invalid_input_type"}}"
+				data-file-too-big="{{.i18n.Tr "dropzone.file_too_big"}}"
+				data-remove-file="{{.i18n.Tr "dropzone.remove_file"}}">
+			</div>
+		{{end}}
 		<div class="text right edit buttons">
 			<div class="ui basic blue cancel button" tabindex="3">{{.i18n.Tr "repo.issues.cancel"}}</div>
 			<div class="ui green save button" tabindex="2">{{.i18n.Tr "repo.issues.save"}}</div>
diff --git a/templates/repo/issue/view_content/attachments.tmpl b/templates/repo/issue/view_content/attachments.tmpl
new file mode 100644
index 000000000..e2d7d1b9d
--- /dev/null
+++ b/templates/repo/issue/view_content/attachments.tmpl
@@ -0,0 +1,9 @@
+{{range .Attachments}}
+  <a target="_blank" rel="noopener noreferrer" href="{{AppSubUrl}}/attachments/{{.UUID}}">
+    {{if FilenameIsImage .Name}}
+      <img class="ui image" src="{{AppSubUrl}}/attachments/{{.UUID}}" title='{{$.ctx.i18n.Tr "repo.issues.attachment.open_tab" .Name}}'>
+    {{else}}
+      <span class="ui image octicon octicon-desktop-download" title='{{$.ctx.i18n.Tr "repo.issues.attachment.download" .Name}}'></span>
+    {{end}}
+  </a>
+{{end}}
\ No newline at end of file
diff --git a/templates/repo/issue/view_content/comments.tmpl b/templates/repo/issue/view_content/comments.tmpl
index a5f25954c..e3ea9ba82 100644
--- a/templates/repo/issue/view_content/comments.tmpl
+++ b/templates/repo/issue/view_content/comments.tmpl
@@ -55,7 +55,7 @@
 						{{end}}
 					</div>
 					<div class="raw-content hide">{{.Content}}</div>
-					<div class="edit-content-zone hide" data-write="issuecomment-{{.ID}}-write" data-preview="issuecomment-{{.ID}}-preview" data-update-url="{{$.RepoLink}}/comments/{{.ID}}" data-context="{{$.RepoLink}}"></div>
+					<div class="edit-content-zone hide" data-write="issuecomment-{{.ID}}-write" data-preview="issuecomment-{{.ID}}-preview" data-update-url="{{$.RepoLink}}/comments/{{.ID}}" data-context="{{$.RepoLink}}" data-attachment-url="{{$.RepoLink}}/comments/{{.ID}}/attachments"></div>
 				</div>
 				{{$reactions := .Reactions.GroupByType}}
 				{{if $reactions}}
@@ -66,15 +66,7 @@
 				{{if .Attachments}}
 					<div class="ui bottom attached segment">
 						<div class="ui small images">
-							{{range .Attachments}}
-								<a target="_blank" rel="noopener noreferrer" href="{{AppSubUrl}}/attachments/{{.UUID}}">
-									{{if FilenameIsImage .Name}}
-										<img class="ui image" src="{{AppSubUrl}}/attachments/{{.UUID}}" title='{{$.i18n.Tr "repo.issues.attachment.open_tab" .Name}}'>
-									{{else}}
-										<span class="ui image octicon octicon-desktop-download" title='{{$.i18n.Tr "repo.issues.attachment.download" .Name}}'></span>
-									{{end}}
-								</a>
-							{{end}}
+							{{template "repo/issue/view_content/attachments" Dict "ctx" $ "Attachments" .Attachments}}
 						</div>
 					</div>
 				{{end}}