| @@ -238,6 +238,7 @@ func runWeb(*cli.Context) { | |||
| r.Post("/:index/label", repo.UpdateIssueLabel) | |||
| r.Post("/:index/milestone", repo.UpdateIssueMilestone) | |||
| r.Post("/:index/assignee", repo.UpdateAssignee) | |||
| r.Get("/:index/attachment/:id", repo.IssueGetAttachment) | |||
| r.Post("/labels/new", bindIgnErr(auth.CreateLabelForm{}), repo.NewLabel) | |||
| r.Post("/labels/edit", bindIgnErr(auth.CreateLabelForm{}), repo.UpdateLabel) | |||
| r.Post("/labels/delete", repo.DeleteLabel) | |||
| @@ -254,13 +255,6 @@ func runWeb(*cli.Context) { | |||
| r.Get("/releases/edit/:tagname", repo.EditRelease) | |||
| }, reqSignIn, middleware.RepoAssignment(true)) | |||
| m.Group("/:username/:reponame/issues/:index/attachment", func(r martini.Router) { | |||
| r.Get("/:id", repo.IssueGetAttachment) | |||
| r.Post("/", repo.IssuePostAttachment) | |||
| r.Post("/:comment", repo.IssuePostAttachment) | |||
| r.Delete("/:comment/:id", repo.IssueDeleteAttachment) | |||
| }, reqSignIn, middleware.RepoAssignment(true), middleware.Toggle(&middleware.ToggleOptions{DisableCsrf: true})) | |||
| m.Group("/:username/:reponame", func(r martini.Router) { | |||
| r.Post("/releases/new", bindIgnErr(auth.NewReleaseForm{}), repo.NewReleasePost) | |||
| r.Post("/releases/edit/:tagname", bindIgnErr(auth.EditReleaseForm{}), repo.EditReleasePost) | |||
| @@ -1085,21 +1085,3 @@ func DeleteAttachmentsByComment(commentId int64, remove bool) (int, error) { | |||
| return DeleteAttachments(attachments, remove) | |||
| } | |||
| // AssignAttachment assigns the given attachment to the specified comment | |||
| func AssignAttachment(issueId, commentId, attachmentId int64) error { | |||
| a, err := GetAttachmentById(attachmentId) | |||
| if err != nil { | |||
| return err | |||
| } | |||
| if a.IssueId != issueId { | |||
| return ErrAttachmentNotLinked | |||
| } | |||
| a.CommentId = commentId | |||
| _, err = x.Id(a.Id).Update(a) | |||
| return err | |||
| } | |||
| @@ -323,7 +323,6 @@ func (f *Flash) Success(msg string) { | |||
| // InitContext initializes a classic context for a request. | |||
| func InitContext() martini.Handler { | |||
| return func(res http.ResponseWriter, r *http.Request, c martini.Context, rd *Render) { | |||
| ctx := &Context{ | |||
| c: c, | |||
| // p: p, | |||
| @@ -332,7 +331,6 @@ func InitContext() martini.Handler { | |||
| Cache: setting.Cache, | |||
| Render: rd, | |||
| } | |||
| ctx.Data["PageStartTime"] = time.Now() | |||
| // start session | |||
| @@ -374,6 +372,14 @@ func InitContext() martini.Handler { | |||
| ctx.Data["IsAdmin"] = ctx.User.IsAdmin | |||
| } | |||
| // If request sends files, parse them here otherwise the Query() can't be parsed and the CsrfToken will be invalid. | |||
| if strings.Contains(r.Header.Get("Content-Type"), "multipart/form-data") { | |||
| if err = ctx.Req.ParseMultipartForm(setting.AttachmentMaxSize << 20); err != nil { // 32MB max size | |||
| ctx.Handle(500, "issue.Comment(ctx.Req.ParseMultipartForm)", err) | |||
| return | |||
| } | |||
| } | |||
| // get or create csrf token | |||
| ctx.Data["CsrfToken"] = ctx.CsrfToken() | |||
| ctx.Data["CsrfTokenHtml"] = template.HTML(`<input type="hidden" name="_csrf" value="` + ctx.csrfToken + `">`) | |||
| @@ -74,6 +74,8 @@ var ( | |||
| // Attachment settings. | |||
| AttachmentPath string | |||
| AttachmentAllowedTypes string | |||
| AttachmentMaxSize int64 | |||
| AttachmentMaxFiles int | |||
| // Cache settings. | |||
| Cache cache.Cache | |||
| @@ -172,6 +174,8 @@ func NewConfigContext() { | |||
| AttachmentPath = Cfg.MustValue("attachment", "PATH", "files/attachments") | |||
| AttachmentAllowedTypes = Cfg.MustValue("attachment", "ALLOWED_TYPES", "*/*") | |||
| AttachmentMaxSize = Cfg.MustInt64("attachment", "MAX_SIZE", 32) | |||
| AttachmentMaxFiles = Cfg.MustInt("attachment", "MAX_FILES", 10) | |||
| if err = os.MkdirAll(AttachmentPath, os.ModePerm); err != nil { | |||
| log.Fatal("Could not create directory %s: %s", AttachmentPath, err) | |||
| @@ -1819,4 +1819,21 @@ body { | |||
| .attachment-preview-img { | |||
| border: 1px solid #d8d8d8; | |||
| } | |||
| #attachments-button { | |||
| float: left; | |||
| } | |||
| #attached { | |||
| height: 18px; | |||
| margin: 10px 10px 15px 10px; | |||
| } | |||
| #attached-list .label { | |||
| margin-right: 10px; | |||
| } | |||
| #issue-create-form #attached { | |||
| margin-bottom: 0; | |||
| } | |||
| @@ -536,7 +536,7 @@ function initIssue() { | |||
| var over = function() { | |||
| var $this = $(this); | |||
| if ($this.text().match(/\.(png|jpg|jpeg|gif)$/) == false) { | |||
| if ($this.text().match(/\.(png|jpg|jpeg|gif)$/i) == false) { | |||
| return; | |||
| } | |||
| @@ -576,15 +576,30 @@ function initIssue() { | |||
| // Upload. | |||
| (function() { | |||
| var $attached = $("#attached"); | |||
| var $attachments = $("input[name=attachments]"); | |||
| var $attachedList = $("#attached-list"); | |||
| var $addButton = $("#attachments-button"); | |||
| var commentId = $addButton.attr("data-comment-id"); // "0" == for issue, "" == for comment | |||
| var accepted = $addButton.attr("data-accept"); | |||
| var fileInput = $("#attachments-input")[0]; | |||
| fileInput.addEventListener("change", function(event) { | |||
| $attachedList.empty(); | |||
| $attachedList.append("<b>Attachments:</b> "); | |||
| for (var index = 0; index < fileInput.files.length; index++) { | |||
| var file = fileInput.files[index]; | |||
| var $span = $("<span></span>"); | |||
| $span.addClass("label"); | |||
| $span.addClass("label-default"); | |||
| $span.append(file.name.toLowerCase()); | |||
| $attachedList.append($span); | |||
| } | |||
| }); | |||
| $addButton.on("click", function() { | |||
| // TODO: (nuss-justin): open dialog, upload file, add id to list, add file to $attached list | |||
| fileInput.click(); | |||
| return false; | |||
| }); | |||
| }()); | |||
| @@ -5,6 +5,7 @@ | |||
| package repo | |||
| import ( | |||
| "errors" | |||
| "fmt" | |||
| "io" | |||
| "io/ioutil" | |||
| @@ -35,6 +36,11 @@ const ( | |||
| MILESTONE_EDIT base.TplName = "repo/issue/milestone_edit" | |||
| ) | |||
| var ( | |||
| ErrFileTypeForbidden = errors.New("File type is not allowed") | |||
| ErrTooManyFiles = errors.New("Maximum number of files to upload exceeded") | |||
| ) | |||
| func Issues(ctx *middleware.Context) { | |||
| ctx.Data["Title"] = "Issues" | |||
| ctx.Data["IsRepoToolbarIssues"] = true | |||
| @@ -233,6 +239,8 @@ func CreateIssuePost(ctx *middleware.Context, params martini.Params, form auth.C | |||
| return | |||
| } | |||
| uploadFiles(ctx, issue.Id, 0) | |||
| // Update mentions. | |||
| ms := base.MentionPattern.FindAllString(issue.Content, -1) | |||
| if len(ms) > 0 { | |||
| @@ -619,6 +627,67 @@ func UpdateAssignee(ctx *middleware.Context) { | |||
| }) | |||
| } | |||
| func uploadFiles(ctx *middleware.Context, issueId, commentId int64) { | |||
| allowedTypes := strings.Split(setting.AttachmentAllowedTypes, "|") | |||
| attachments := ctx.Req.MultipartForm.File["attachments"] | |||
| if len(attachments) > setting.AttachmentMaxFiles { | |||
| ctx.Handle(400, "issue.Comment", ErrTooManyFiles) | |||
| return | |||
| } | |||
| for _, header := range attachments { | |||
| file, err := header.Open() | |||
| if err != nil { | |||
| ctx.Handle(500, "issue.Comment(header.Open)", err) | |||
| return | |||
| } | |||
| defer file.Close() | |||
| allowed := false | |||
| fileType := mime.TypeByExtension(header.Filename) | |||
| for _, t := range allowedTypes { | |||
| t := strings.Trim(t, " ") | |||
| if t == "*/*" || t == fileType { | |||
| allowed = true | |||
| break | |||
| } | |||
| } | |||
| if !allowed { | |||
| ctx.Handle(400, "issue.Comment", ErrFileTypeForbidden) | |||
| return | |||
| } | |||
| out, err := ioutil.TempFile(setting.AttachmentPath, "attachment_") | |||
| if err != nil { | |||
| ctx.Handle(500, "issue.Comment(ioutil.TempFile)", err) | |||
| return | |||
| } | |||
| defer out.Close() | |||
| _, err = io.Copy(out, file) | |||
| if err != nil { | |||
| ctx.Handle(500, "issue.Comment(io.Copy)", err) | |||
| return | |||
| } | |||
| _, err = models.CreateAttachment(issueId, commentId, header.Filename, out.Name()) | |||
| if err != nil { | |||
| ctx.Handle(500, "issue.Comment(io.Copy)", err) | |||
| return | |||
| } | |||
| } | |||
| } | |||
| func Comment(ctx *middleware.Context, params martini.Params) { | |||
| index, err := base.StrTo(ctx.Query("issueIndex")).Int64() | |||
| if err != nil { | |||
| @@ -706,28 +775,8 @@ func Comment(ctx *middleware.Context, params martini.Params) { | |||
| } | |||
| } | |||
| attachments := strings.Split(params["attachments"], ",") | |||
| for _, a := range attachments { | |||
| a = strings.Trim(a, " ") | |||
| if len(a) == 0 { | |||
| continue | |||
| } | |||
| aId, err := base.StrTo(a).Int64() | |||
| if err != nil { | |||
| ctx.Handle(400, "issue.Comment(base.StrTo.Int64)", err) | |||
| return | |||
| } | |||
| err = models.AssignAttachment(issue.Id, comment.Id, aId) | |||
| if err != nil { | |||
| ctx.Handle(400, "issue.Comment(models.AssignAttachment)", err) | |||
| return | |||
| } | |||
| if comment != nil { | |||
| uploadFiles(ctx, issue.Id, comment.Id) | |||
| } | |||
| // Notify watchers. | |||
| @@ -1007,122 +1056,6 @@ func UpdateMilestonePost(ctx *middleware.Context, params martini.Params, form au | |||
| ctx.Redirect(ctx.Repo.RepoLink + "/issues/milestones") | |||
| } | |||
| func IssuePostAttachment(ctx *middleware.Context, params martini.Params) { | |||
| index, _ := base.StrTo(params["index"]).Int64() | |||
| if index == 0 { | |||
| ctx.JSON(400, map[string]interface{}{ | |||
| "ok": false, | |||
| "error": "invalid issue index", | |||
| }) | |||
| return | |||
| } | |||
| issue, err := models.GetIssueByIndex(ctx.Repo.Repository.Id, index) | |||
| if err != nil { | |||
| ctx.JSON(400, map[string]interface{}{ | |||
| "ok": false, | |||
| "error": "invalid comment id", | |||
| }) | |||
| return | |||
| } | |||
| commentId, err := base.StrTo(params["comment"]).Int64() | |||
| if err != nil && len(params["comment"]) > 0 { | |||
| ctx.JSON(400, map[string]interface{}{ | |||
| "ok": false, | |||
| "error": "invalid comment id", | |||
| }) | |||
| return | |||
| } | |||
| if commentId == 0 { | |||
| commentId = -1 | |||
| } | |||
| file, header, err := ctx.Req.FormFile("attachment") | |||
| if err != nil { | |||
| ctx.JSON(400, map[string]interface{}{ | |||
| "ok": false, | |||
| "error": "upload error", | |||
| }) | |||
| return | |||
| } | |||
| defer file.Close() | |||
| // check mime type, write to file, insert attachment to db | |||
| allowedTypes := strings.Split(setting.AttachmentAllowedTypes, "|") | |||
| allowed := false | |||
| fileType := mime.TypeByExtension(header.Filename) | |||
| for _, t := range allowedTypes { | |||
| t := strings.Trim(t, " ") | |||
| if t == "*/*" || t == fileType { | |||
| allowed = true | |||
| break | |||
| } | |||
| } | |||
| if !allowed { | |||
| ctx.JSON(400, map[string]interface{}{ | |||
| "ok": false, | |||
| "error": "mime type not allowed", | |||
| }) | |||
| return | |||
| } | |||
| out, err := ioutil.TempFile(setting.AttachmentPath, "attachment_") | |||
| if err != nil { | |||
| ctx.JSON(500, map[string]interface{}{ | |||
| "ok": false, | |||
| "error": "internal server error", | |||
| }) | |||
| return | |||
| } | |||
| defer out.Close() | |||
| _, err = io.Copy(out, file) | |||
| if err != nil { | |||
| ctx.JSON(500, map[string]interface{}{ | |||
| "ok": false, | |||
| "error": "internal server error", | |||
| }) | |||
| return | |||
| } | |||
| a, err := models.CreateAttachment(issue.Id, commentId, header.Filename, out.Name()) | |||
| if err != nil { | |||
| ctx.JSON(500, map[string]interface{}{ | |||
| "ok": false, | |||
| "error": "internal server error", | |||
| }) | |||
| return | |||
| } | |||
| ctx.JSON(500, map[string]interface{}{ | |||
| "ok": true, | |||
| "id": a.Id, | |||
| }) | |||
| } | |||
| func IssueGetAttachment(ctx *middleware.Context, params martini.Params) { | |||
| id, err := base.StrTo(params["id"]).Int64() | |||
| @@ -1138,117 +1071,5 @@ func IssueGetAttachment(ctx *middleware.Context, params martini.Params) { | |||
| return | |||
| } | |||
| log.Error("path=%s name=%s", attachment.Path, attachment.Name) | |||
| ctx.ServeFile(attachment.Path, attachment.Name) | |||
| } | |||
| func IssueDeleteAttachment(ctx *middleware.Context, params martini.Params) { | |||
| index, _ := base.StrTo(params["index"]).Int64() | |||
| if index == 0 { | |||
| ctx.JSON(400, map[string]interface{}{ | |||
| "ok": false, | |||
| "error": "invalid issue index", | |||
| }) | |||
| return | |||
| } | |||
| issue, err := models.GetIssueByIndex(ctx.Repo.Repository.Id, index) | |||
| if err != nil { | |||
| ctx.JSON(400, map[string]interface{}{ | |||
| "ok": false, | |||
| "error": "invalid comment id", | |||
| }) | |||
| return | |||
| } | |||
| commentId, err := base.StrTo(params["comment"]).Int64() | |||
| if err != nil || commentId < 0 { | |||
| ctx.JSON(400, map[string]interface{}{ | |||
| "ok": false, | |||
| "error": "invalid comment id", | |||
| }) | |||
| return | |||
| } | |||
| comment, err := models.GetCommentById(commentId) | |||
| if err != nil { | |||
| ctx.JSON(400, map[string]interface{}{ | |||
| "ok": false, | |||
| "error": "invalid issue id", | |||
| }) | |||
| return | |||
| } | |||
| if comment.PosterId != ctx.User.Id && !ctx.User.IsAdmin { | |||
| ctx.JSON(400, map[string]interface{}{ | |||
| "ok": false, | |||
| "error": "no permissions", | |||
| }) | |||
| return | |||
| } | |||
| attachmentId, err := base.StrTo(params["id"]).Int64() | |||
| if err != nil { | |||
| ctx.JSON(400, map[string]interface{}{ | |||
| "ok": false, | |||
| "error": "invalid attachment id", | |||
| }) | |||
| return | |||
| } | |||
| attachment, err := models.GetAttachmentById(attachmentId) | |||
| if err != nil { | |||
| ctx.JSON(400, map[string]interface{}{ | |||
| "ok": false, | |||
| "error": "wrong attachment id", | |||
| }) | |||
| return | |||
| } | |||
| if attachment.IssueId != issue.Id { | |||
| ctx.JSON(400, map[string]interface{}{ | |||
| "ok": false, | |||
| "error": "attachment not associated with the given issue", | |||
| }) | |||
| return | |||
| } | |||
| if attachment.CommentId != commentId { | |||
| ctx.JSON(400, map[string]interface{}{ | |||
| "ok": false, | |||
| "error": "attachment not associated with the given comment", | |||
| }) | |||
| return | |||
| } | |||
| err = models.DeleteAttachment(attachment, true) | |||
| if err != nil { | |||
| ctx.JSON(500, map[string]interface{}{ | |||
| "ok": false, | |||
| "error": "could not delete attachment", | |||
| }) | |||
| return | |||
| } | |||
| ctx.JSON(200, map[string]interface{}{ | |||
| "ok": true, | |||
| }) | |||
| } | |||
| @@ -4,7 +4,7 @@ | |||
| {{template "repo/toolbar" .}} | |||
| <div id="body" class="container"> | |||
| <div id="issue"> | |||
| <form class="form" action="{{.RepoLink}}/issues/new" method="post" id="issue-create-form"> | |||
| <form class="form" action="{{.RepoLink}}/issues/new" method="post" id="issue-create-form" enctype="multipart/form-data"> | |||
| {{.CsrfTokenHtml}} | |||
| {{template "base/alert" .}} | |||
| <div class="col-md-1"> | |||
| @@ -101,18 +101,13 @@ | |||
| <div class="tab-pane issue-preview-content" id="issue-preview">loading...</div> | |||
| </div> | |||
| </div> | |||
| <!-- | |||
| <div> | |||
| <div id="attached"></div> | |||
| <div id="attached"> | |||
| <div id="attached-list"></div> | |||
| </div> | |||
| --> | |||
| <div class="text-right panel-body"> | |||
| <div class="form-group"> | |||
| <!-- | |||
| <input type="hidden" name="attachments" value="" /> | |||
| <button data-accept="{{.AllowedTypes}}" data-comment-id="0" class="btn-default btn attachment-add" id="attachments-button">Add Attachments...</button> | |||
| --> | |||
| <input type="file" accept="{{.AllowedTypes}}" style="display: none;" id="attachments-input" name="attachments" multiple /> | |||
| <button class="btn-default btn attachment-add" id="attachments-button">Select Attachments...</button> | |||
| <input type="hidden" value="id" name="repo-id"/> | |||
| <button class="btn-success btn">Create new issue</button> | |||
| </div> | |||
| @@ -117,7 +117,7 @@ | |||
| <hr class="issue-line"/> | |||
| {{if .SignedUser}}<div class="issue-child issue-reply"> | |||
| <a class="user pull-left" href="/user/{{.SignedUser.Name}}"><img class="avatar" src="{{.SignedUser.AvatarLink}}" alt=""/></a> | |||
| <form class="panel panel-default issue-content" action="{{.RepoLink}}/comment/new" method="post"> | |||
| <form class="panel panel-default issue-content" action="{{.RepoLink}}/comment/new" method="post" enctype="multipart/form-data"> | |||
| {{.CsrfTokenHtml}} | |||
| <div class="panel-body"> | |||
| <div class="form-group"> | |||
| @@ -137,18 +137,13 @@ | |||
| <div class="tab-pane issue-preview-content" id="issue-preview">Loading...</div> | |||
| </div> | |||
| </div> | |||
| <!-- | |||
| <div> | |||
| <div id="attached"></div> | |||
| <div id="attached"> | |||
| <div id="attached-list"></div> | |||
| </div> | |||
| --> | |||
| <div class="text-right"> | |||
| <div class="form-group"> | |||
| <!-- | |||
| <input type="hidden" name="attachments" value="" /> | |||
| <button data-accept="{{.AllowedTypes}}" class="btn-default btn attachment-add" id="attachments-button">Add Attachments...</button> | |||
| --> | |||
| <input type="file" accept="{{.AllowedTypes}}" style="display: none;" id="attachments-input" name="attachments" multiple /> | |||
| <button class="btn-default btn attachment-add" id="attachments-button">Select Attachments...</button> | |||
| {{if .IsIssueOwner}}{{if .Issue.IsClosed}} | |||
| <input type="submit" class="btn-default btn issue-open" id="issue-open-btn" data-origin="Reopen" data-text="Reopen & Comment" name="change_status" value="Reopen"/>{{else}} | |||
| <input type="submit" class="btn-default btn issue-close" id="issue-close-btn" data-origin="Close" data-text="Close & Comment" name="change_status" value="Close"/>{{end}}{{end}} | |||