From 4617bef8954deeef5bd2ba36d84aba3b05a4dd83 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Justin=20Nu=C3=9F?= <nuss.justin@gmail.com>
Date: Wed, 23 Jul 2014 21:15:47 +0200
Subject: [PATCH 01/12] WIP: Allow attachments for comments

---
 cmd/web.go                     |   5 +-
 conf/app.ini                   |   5 +
 models/issue.go                | 192 ++++++++++++++++++++++++++++++---
 models/models.go               |   2 +-
 modules/setting/setting.go     |  11 ++
 public/js/app.js               |  13 +++
 routers/repo/issue.go          | 144 ++++++++++++++++++++++++-
 templates/repo/issue/view.tmpl |  11 ++
 8 files changed, 366 insertions(+), 17 deletions(-)

diff --git a/cmd/web.go b/cmd/web.go
index d3ce68d370..48622b55cd 100644
--- a/cmd/web.go
+++ b/cmd/web.go
@@ -206,7 +206,7 @@ func runWeb(*cli.Context) {
 		r.Post("/:org/teams/new", bindIgnErr(auth.CreateTeamForm{}), org.NewTeamPost)
 		r.Get("/:org/teams/:team/edit", org.EditTeam)
 
-		r.Get("/:org/team/:team",org.SingleTeam)
+		r.Get("/:org/team/:team", org.SingleTeam)
 
 		r.Get("/:org/settings", org.Settings)
 		r.Post("/:org/settings", bindIgnErr(auth.OrgSettingForm{}), org.SettingsPost)
@@ -238,6 +238,9 @@ func runWeb(*cli.Context) {
 			r.Post("/:index/label", repo.UpdateIssueLabel)
 			r.Post("/:index/milestone", repo.UpdateIssueMilestone)
 			r.Post("/:index/assignee", repo.UpdateAssignee)
+			r.Post("/:index/attachment", repo.IssuePostAttachment)
+			r.Post("/:index/attachment/:id", repo.IssuePostAttachment)
+			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)
diff --git a/conf/app.ini b/conf/app.ini
index 296509f721..3cea1fdd0f 100644
--- a/conf/app.ini
+++ b/conf/app.ini
@@ -180,6 +180,11 @@ SESSION_ID_HASHKEY =
 SERVICE = server
 DISABLE_GRAVATAR = false
 
+[attachment]
+PATH = 
+; One or more allowed types, e.g. image/jpeg|image/png
+ALLOWED_TYPES = 
+
 [log]
 ROOT_PATH =
 ; Either "console", "file", "conn", "smtp" or "database", default is "console"
diff --git a/models/issue.go b/models/issue.go
index 6253837237..90ef287cec 100644
--- a/models/issue.go
+++ b/models/issue.go
@@ -7,19 +7,24 @@ package models
 import (
 	"bytes"
 	"errors"
+	"os"
+	"strconv"
 	"strings"
 	"time"
 
 	"github.com/go-xorm/xorm"
 
 	"github.com/gogits/gogs/modules/base"
+	"github.com/gogits/gogs/modules/log"
 )
 
 var (
-	ErrIssueNotExist     = errors.New("Issue does not exist")
-	ErrLabelNotExist     = errors.New("Label does not exist")
-	ErrMilestoneNotExist = errors.New("Milestone does not exist")
-	ErrWrongIssueCounter = errors.New("Invalid number of issues for this milestone")
+	ErrIssueNotExist       = errors.New("Issue does not exist")
+	ErrLabelNotExist       = errors.New("Label does not exist")
+	ErrMilestoneNotExist   = errors.New("Milestone does not exist")
+	ErrWrongIssueCounter   = errors.New("Invalid number of issues for this milestone")
+	ErrAttachmentNotExist  = errors.New("Attachment does not exist")
+	ErrAttachmentNotLinked = errors.New("Attachment does not belong to this issue")
 )
 
 // Issue represents an issue or pull request of repository.
@@ -91,6 +96,14 @@ func (i *Issue) GetAssignee() (err error) {
 	return err
 }
 
+func (i *Issue) AfterDelete() {
+	_, err := DeleteAttachmentsByIssue(i.Id, true)
+
+	if err != nil {
+		log.Info("Could not delete files for issue #%d: %s", i.Id, err)
+	}
+}
+
 // CreateIssue creates new issue for repository.
 func NewIssue(issue *Issue) (err error) {
 	sess := x.NewSession()
@@ -795,17 +808,19 @@ type Comment struct {
 }
 
 // CreateComment creates comment of issue or commit.
-func CreateComment(userId, repoId, issueId, commitId, line int64, cmtType int, content string) error {
+func CreateComment(userId, repoId, issueId, commitId, line int64, cmtType int, content string, attachments []int64) (*Comment, error) {
 	sess := x.NewSession()
 	defer sess.Close()
 	if err := sess.Begin(); err != nil {
-		return err
+		return nil, err
 	}
 
-	if _, err := sess.Insert(&Comment{PosterId: userId, Type: cmtType, IssueId: issueId,
-		CommitId: commitId, Line: line, Content: content}); err != nil {
+	comment := &Comment{PosterId: userId, Type: cmtType, IssueId: issueId,
+		CommitId: commitId, Line: line, Content: content}
+
+	if _, err := sess.Insert(comment); err != nil {
 		sess.Rollback()
-		return err
+		return nil, err
 	}
 
 	// Check comment type.
@@ -814,22 +829,38 @@ func CreateComment(userId, repoId, issueId, commitId, line int64, cmtType int, c
 		rawSql := "UPDATE `issue` SET num_comments = num_comments + 1 WHERE id = ?"
 		if _, err := sess.Exec(rawSql, issueId); err != nil {
 			sess.Rollback()
-			return err
+			return nil, err
+		}
+
+		if len(attachments) > 0 {
+			rawSql = "UPDATE `attachment` SET comment_id = ? WHERE id IN (?)"
+
+			astrs := make([]string, 0, len(attachments))
+
+			for _, a := range attachments {
+				astrs = append(astrs, strconv.FormatInt(a, 10))
+			}
+
+			if _, err := sess.Exec(rawSql, comment.Id, strings.Join(astrs, ",")); err != nil {
+				sess.Rollback()
+				return nil, err
+			}
 		}
 	case IT_REOPEN:
 		rawSql := "UPDATE `repository` SET num_closed_issues = num_closed_issues - 1 WHERE id = ?"
 		if _, err := sess.Exec(rawSql, repoId); err != nil {
 			sess.Rollback()
-			return err
+			return nil, err
 		}
 	case IT_CLOSE:
 		rawSql := "UPDATE `repository` SET num_closed_issues = num_closed_issues + 1 WHERE id = ?"
 		if _, err := sess.Exec(rawSql, repoId); err != nil {
 			sess.Rollback()
-			return err
+			return nil, err
 		}
 	}
-	return sess.Commit()
+
+	return comment, sess.Commit()
 }
 
 // GetIssueComments returns list of comment by given issue id.
@@ -838,3 +869,138 @@ func GetIssueComments(issueId int64) ([]Comment, error) {
 	err := x.Asc("created").Find(&comments, &Comment{IssueId: issueId})
 	return comments, err
 }
+
+// Attachments returns the attachments for this comment.
+func (c *Comment) Attachments() ([]*Attachment, error) {
+	return GetAttachmentsByComment(c.Id)
+}
+
+func (c *Comment) AfterDelete() {
+	_, err := DeleteAttachmentsByComment(c.Id, true)
+
+	if err != nil {
+		log.Info("Could not delete files for comment %d on issue #%d: %s", c.Id, c.IssueId, err)
+	}
+}
+
+type Attachment struct {
+	Id        int64
+	IssueId   int64
+	CommentId int64
+	Name      string
+	Path      string
+	Created   time.Time `xorm:"CREATED"`
+}
+
+// CreateAttachment creates a new attachment inside the database and
+func CreateAttachment(issueId, commentId int64, name, path string) (*Attachment, error) {
+	sess := x.NewSession()
+	defer sess.Close()
+
+	if err := sess.Begin(); err != nil {
+		return nil, err
+	}
+
+	a := &Attachment{IssueId: issueId, CommentId: commentId, Name: name, Path: path}
+
+	if _, err := sess.Insert(a); err != nil {
+		sess.Rollback()
+		return nil, err
+	}
+
+	return a, sess.Commit()
+}
+
+// Attachment returns the attachment by given ID.
+func GetAttachmentById(id int64) (*Attachment, error) {
+	m := &Attachment{Id: id}
+
+	has, err := x.Get(m)
+
+	if err != nil {
+		return nil, err
+	}
+
+	if !has {
+		return nil, ErrAttachmentNotExist
+	}
+
+	return m, nil
+}
+
+// GetAttachmentsByIssue returns a list of attachments for the given issue
+func GetAttachmentsByIssue(issueId int64) ([]*Attachment, error) {
+	attachments := make([]*Attachment, 0, 10)
+	err := x.Where("issue_id = ?", issueId).Find(&attachments)
+	return attachments, err
+}
+
+// GetAttachmentsByComment returns a list of attachments for the given comment
+func GetAttachmentsByComment(commentId int64) ([]*Attachment, error) {
+	attachments := make([]*Attachment, 0, 10)
+	err := x.Where("comment_id = ?", commentId).Find(&attachments)
+	return attachments, err
+}
+
+// DeleteAttachment deletes the given attachment and optionally the associated file.
+func DeleteAttachment(a *Attachment, remove bool) error {
+	_, err := DeleteAttachments([]*Attachment{a}, remove)
+	return err
+}
+
+// DeleteAttachments deletes the given attachments and optionally the associated files.
+func DeleteAttachments(attachments []*Attachment, remove bool) (int, error) {
+	for i, a := range attachments {
+		if remove {
+			if err := os.Remove(a.Path); err != nil {
+				return i, err
+			}
+		}
+
+		if _, err := x.Delete(a.Id); err != nil {
+			return i, err
+		}
+	}
+
+	return len(attachments), nil
+}
+
+// DeleteAttachmentsByIssue deletes all attachments associated with the given issue.
+func DeleteAttachmentsByIssue(issueId int64, remove bool) (int, error) {
+	attachments, err := GetAttachmentsByIssue(issueId)
+
+	if err != nil {
+		return 0, err
+	}
+
+	return DeleteAttachments(attachments, remove)
+}
+
+// DeleteAttachmentsByComment deletes all attachments associated with the given comment.
+func DeleteAttachmentsByComment(commentId int64, remove bool) (int, error) {
+	attachments, err := GetAttachmentsByComment(commentId)
+
+	if err != nil {
+		return 0, err
+	}
+
+	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
+}
diff --git a/models/models.go b/models/models.go
index ded8b05984..31509ed349 100644
--- a/models/models.go
+++ b/models/models.go
@@ -36,7 +36,7 @@ func init() {
 		new(Action), new(Access), new(Issue), new(Comment), new(Oauth2), new(Follow),
 		new(Mirror), new(Release), new(LoginSource), new(Webhook), new(IssueUser),
 		new(Milestone), new(Label), new(HookTask), new(Team), new(OrgUser), new(TeamUser),
-		new(UpdateTask))
+		new(UpdateTask), new(Attachment))
 }
 
 func LoadModelsConfig() {
diff --git a/modules/setting/setting.go b/modules/setting/setting.go
index f03aa8aeae..ba9e86dc8f 100644
--- a/modules/setting/setting.go
+++ b/modules/setting/setting.go
@@ -71,6 +71,10 @@ var (
 	LogModes    []string
 	LogConfigs  []string
 
+	// Attachment settings.
+	AttachmentPath         string
+	AttachmentAllowedTypes string
+
 	// Cache settings.
 	Cache        cache.Cache
 	CacheAdapter string
@@ -166,6 +170,13 @@ func NewConfigContext() {
 	CookieRememberName = Cfg.MustValue("security", "COOKIE_REMEMBER_NAME")
 	ReverseProxyAuthUser = Cfg.MustValue("security", "REVERSE_PROXY_AUTHENTICATION_USER", "X-WEBAUTH-USER")
 
+	AttachmentPath = Cfg.MustValue("attachment", "PATH", "files/attachments")
+	AttachmentAllowedTypes = Cfg.MustValue("attachment", "ALLOWED_TYPES", "*/*")
+
+	if err = os.MkdirAll(AttachmentPath, os.ModePerm); err != nil {
+		log.Fatal("Could not create directory %s: %s", AttachmentPath, err)
+	}
+
 	RunUser = Cfg.MustValue("", "RUN_USER")
 	curUser := os.Getenv("USER")
 	if len(curUser) == 0 {
diff --git a/public/js/app.js b/public/js/app.js
index 7d4e7839b4..77719efe09 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -520,6 +520,19 @@ function initIssue() {
         });
     }());
 
+    (function() {
+        var $attached = $("#attached");
+        var $attachments = $("input[name=attachments]");
+        var $addButton = $("#attachments-button");
+
+        var accepted = $addButton.attr("data-accept");
+
+        $addButton.on("click", function() {
+            // TODO: (nuss-justin): open dialog, upload file, add id to list, add file to $attached list
+            return false;
+        });
+    }());
+
     // issue edit mode
     (function () {
         $("#issue-edit-btn").on("click", function () {
diff --git a/routers/repo/issue.go b/routers/repo/issue.go
index e71c835fd8..9a265e0959 100644
--- a/routers/repo/issue.go
+++ b/routers/repo/issue.go
@@ -6,6 +6,9 @@ package repo
 
 import (
 	"fmt"
+	"io"
+	"io/ioutil"
+	"mime"
 	"net/url"
 	"strings"
 	"time"
@@ -396,6 +399,8 @@ func ViewIssue(ctx *middleware.Context, params martini.Params) {
 		comments[i].Content = string(base.RenderMarkdown([]byte(comments[i].Content), ctx.Repo.RepoLink))
 	}
 
+	ctx.Data["AllowedTypes"] = setting.AttachmentAllowedTypes
+
 	ctx.Data["Title"] = issue.Name
 	ctx.Data["Issue"] = issue
 	ctx.Data["Comments"] = comments
@@ -670,7 +675,7 @@ func Comment(ctx *middleware.Context, params martini.Params) {
 				cmtType = models.IT_REOPEN
 			}
 
-			if err = models.CreateComment(ctx.User.Id, ctx.Repo.Repository.Id, issue.Id, 0, 0, cmtType, ""); err != nil {
+			if _, err = models.CreateComment(ctx.User.Id, ctx.Repo.Repository.Id, issue.Id, 0, 0, cmtType, "", nil); err != nil {
 				ctx.Handle(200, "issue.Comment(create status change comment)", err)
 				return
 			}
@@ -678,12 +683,14 @@ func Comment(ctx *middleware.Context, params martini.Params) {
 		}
 	}
 
+	var comment *models.Comment
+
 	var ms []string
 	content := ctx.Query("content")
 	if len(content) > 0 {
 		switch params["action"] {
 		case "new":
-			if err = models.CreateComment(ctx.User.Id, ctx.Repo.Repository.Id, issue.Id, 0, 0, models.IT_PLAIN, content); err != nil {
+			if comment, err = models.CreateComment(ctx.User.Id, ctx.Repo.Repository.Id, issue.Id, 0, 0, models.IT_PLAIN, content, nil); err != nil {
 				ctx.Handle(500, "issue.Comment(create comment)", err)
 				return
 			}
@@ -709,6 +716,24 @@ func Comment(ctx *middleware.Context, params martini.Params) {
 		}
 	}
 
+	attachments := strings.Split(params["attachments"], ",")
+
+	for _, a := range attachments {
+		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
+		}
+	}
+
 	// Notify watchers.
 	act := &models.Action{
 		ActUserId:    ctx.User.Id,
@@ -985,3 +1010,118 @@ 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) {
+	issueId, _ := base.StrTo(params["index"]).Int64()
+
+	if issueId == 0 {
+		ctx.Handle(400, "issue.IssuePostAttachment", nil)
+		return
+	}
+
+	commentId, err := base.StrTo(params["id"]).Int64()
+
+	if err != nil && len(params["id"]) > 0 {
+		ctx.JSON(400, map[string]interface{}{
+			"ok":    false,
+			"error": "invalid comment id",
+		})
+
+		return
+	}
+
+	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(issueId, 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()
+
+	if err != nil {
+		ctx.Handle(400, "issue.IssueGetAttachment(base.StrTo.Int64)", err)
+		return
+	}
+
+	attachment, err := models.GetAttachmentById(id)
+
+	if err != nil {
+		ctx.Handle(404, "issue.IssueGetAttachment(models.GetAttachmentById)", err)
+		return
+	}
+
+	ctx.ServeFile(attachment.Path, attachment.Name)
+}
diff --git a/templates/repo/issue/view.tmpl b/templates/repo/issue/view.tmpl
index d95fba40d8..246055e183 100644
--- a/templates/repo/issue/view.tmpl
+++ b/templates/repo/issue/view.tmpl
@@ -62,6 +62,11 @@
                             <div class="panel-body markdown">
                                 {{str2html .Content}}
                             </div>
+                            <div class="attachments">
+                                {{range .Attachments}}
+                                <a class="attachment" href="{{.IssueId}}/attachment/{{.Id}}">{{.Name}}</a>
+                                {{end}}
+                            </div>
                         </div>
                     </div>
                     {{else if eq .Type 1}}
@@ -103,8 +108,14 @@
                                     <div class="tab-pane issue-preview-content" id="issue-preview">Loading...</div>
                                 </div>
                             </div>
+                            <div>
+                                <div id="attached"></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>
+
                                     {{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}}&nbsp;&nbsp;

From 34304e6a0c9a3904b999e3ae1fcc9e6518d3f026 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Justin=20Nu=C3=9F?= <nuss.justin@gmail.com>
Date: Wed, 23 Jul 2014 21:24:24 +0200
Subject: [PATCH 02/12] WIP: Allow attachments for issues, not only comments

---
 models/issue.go                  | 18 +++++++++++++++---
 public/js/app.js                 |  1 +
 routers/repo/issue.go            |  7 +++++++
 templates/repo/issue/create.tmpl |  6 ++++++
 templates/repo/issue/view.tmpl   |  5 +++++
 5 files changed, 34 insertions(+), 3 deletions(-)

diff --git a/models/issue.go b/models/issue.go
index 90ef287cec..c0f2da2f2b 100644
--- a/models/issue.go
+++ b/models/issue.go
@@ -96,6 +96,11 @@ func (i *Issue) GetAssignee() (err error) {
 	return err
 }
 
+func (i *Issue) Attachments() []*Attachment {
+	a, _ := GetAttachmentsForIssue(i.Id)
+	return a
+}
+
 func (i *Issue) AfterDelete() {
 	_, err := DeleteAttachmentsByIssue(i.Id, true)
 
@@ -871,8 +876,9 @@ func GetIssueComments(issueId int64) ([]Comment, error) {
 }
 
 // Attachments returns the attachments for this comment.
-func (c *Comment) Attachments() ([]*Attachment, error) {
-	return GetAttachmentsByComment(c.Id)
+func (c *Comment) Attachments() []*Attachment {
+	a, _ := GetAttachmentsByComment(c.Id)
+	return a
 }
 
 func (c *Comment) AfterDelete() {
@@ -928,10 +934,16 @@ func GetAttachmentById(id int64) (*Attachment, error) {
 	return m, nil
 }
 
+func GetAttachmentsForIssue(issueId int64) ([]*Attachment, error) {
+	attachments := make([]*Attachment, 0, 10)
+	err := x.Where("issue_id = ?", issueId).Where("comment_id = 0").Find(&attachments)
+	return attachments, err
+}
+
 // GetAttachmentsByIssue returns a list of attachments for the given issue
 func GetAttachmentsByIssue(issueId int64) ([]*Attachment, error) {
 	attachments := make([]*Attachment, 0, 10)
-	err := x.Where("issue_id = ?", issueId).Find(&attachments)
+	err := x.Where("issue_id = ?", issueId).Where("comment_id > 0").Find(&attachments)
 	return attachments, err
 }
 
diff --git a/public/js/app.js b/public/js/app.js
index 77719efe09..16d1d5dab1 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -525,6 +525,7 @@ function initIssue() {
         var $attachments = $("input[name=attachments]");
         var $addButton = $("#attachments-button");
 
+        var commentId = $addButton.attr("data-comment-id"); // "0" == for issue, "" == for comment
         var accepted = $addButton.attr("data-accept");
 
         $addButton.on("click", function() {
diff --git a/routers/repo/issue.go b/routers/repo/issue.go
index 9a265e0959..abcf43a945 100644
--- a/routers/repo/issue.go
+++ b/routers/repo/issue.go
@@ -173,7 +173,10 @@ func CreateIssue(ctx *middleware.Context, params martini.Params) {
 		ctx.Handle(500, "issue.CreateIssue(GetCollaborators)", err)
 		return
 	}
+
+	ctx.Data["AllowedTypes"] = setting.AttachmentAllowedTypes
 	ctx.Data["Collaborators"] = us
+
 	ctx.HTML(200, ISSUE_CREATE)
 }
 
@@ -1030,6 +1033,10 @@ func IssuePostAttachment(ctx *middleware.Context, params martini.Params) {
 		return
 	}
 
+	if commentId == 0 {
+		commentId = -1
+	}
+
 	file, header, err := ctx.Req.FormFile("attachment")
 
 	if err != nil {
diff --git a/templates/repo/issue/create.tmpl b/templates/repo/issue/create.tmpl
index b548b1e749..b7f281c51d 100644
--- a/templates/repo/issue/create.tmpl
+++ b/templates/repo/issue/create.tmpl
@@ -101,8 +101,14 @@
                         <div class="tab-pane issue-preview-content" id="issue-preview">loading...</div>
                     </div>
                 </div>
+                <div>
+                    <div id="attached"></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="hidden" value="id" name="repo-id"/>
                         <button class="btn-success btn">Create new issue</button>
                     </div>
diff --git a/templates/repo/issue/view.tmpl b/templates/repo/issue/view.tmpl
index 246055e183..500d3ce3f6 100644
--- a/templates/repo/issue/view.tmpl
+++ b/templates/repo/issue/view.tmpl
@@ -46,6 +46,11 @@
                                     </div>
                                 </div>
                             </div>
+                            <div class="attachments">
+                                {{range .Attachments}}
+                                <a class="attachment" href="{{.IssueId}}/attachment/{{.Id}}">{{.Name}}</a>
+                                {{end}}
+                            </div>                            
                         </div>
                     </div>
                     {{range .Comments}}

From 3c025b395077292a721419942f997311ef575fd9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Justin=20Nu=C3=9F?= <justin.nuss@hmmh.de>
Date: Thu, 24 Jul 2014 09:04:09 +0200
Subject: [PATCH 03/12] Add delete route for attachments, remove upload buttons
 from issues/comments

---
 cmd/web.go                       |  11 +++-
 models/issue.go                  |  12 +++-
 routers/repo/issue.go            | 109 ++++++++++++++++++++++++++++++-
 templates/repo/issue/create.tmpl |   4 ++
 templates/repo/issue/view.tmpl   |   4 ++
 5 files changed, 132 insertions(+), 8 deletions(-)

diff --git a/cmd/web.go b/cmd/web.go
index 48622b55cd..0b7aac33b4 100644
--- a/cmd/web.go
+++ b/cmd/web.go
@@ -238,9 +238,14 @@ func runWeb(*cli.Context) {
 			r.Post("/:index/label", repo.UpdateIssueLabel)
 			r.Post("/:index/milestone", repo.UpdateIssueMilestone)
 			r.Post("/:index/assignee", repo.UpdateAssignee)
-			r.Post("/:index/attachment", repo.IssuePostAttachment)
-			r.Post("/:index/attachment/:id", repo.IssuePostAttachment)
-			r.Get("/:index/attachment/:id", repo.IssueGetAttachment)
+
+			m.Group("/: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)
+			})
+
 			r.Post("/labels/new", bindIgnErr(auth.CreateLabelForm{}), repo.NewLabel)
 			r.Post("/labels/edit", bindIgnErr(auth.CreateLabelForm{}), repo.UpdateLabel)
 			r.Post("/labels/delete", repo.DeleteLabel)
diff --git a/models/issue.go b/models/issue.go
index c0f2da2f2b..43575a07c9 100644
--- a/models/issue.go
+++ b/models/issue.go
@@ -868,6 +868,14 @@ func CreateComment(userId, repoId, issueId, commitId, line int64, cmtType int, c
 	return comment, sess.Commit()
 }
 
+// GetCommentById returns the comment with the given id
+func GetCommentById(commentId int64) (*Comment, error) {
+	c := &Comment{Id: commentId}
+	_, err := x.Get(c)
+
+	return c, err
+}
+
 // GetIssueComments returns list of comment by given issue id.
 func GetIssueComments(issueId int64) ([]Comment, error) {
 	comments := make([]Comment, 0, 10)
@@ -936,14 +944,14 @@ func GetAttachmentById(id int64) (*Attachment, error) {
 
 func GetAttachmentsForIssue(issueId int64) ([]*Attachment, error) {
 	attachments := make([]*Attachment, 0, 10)
-	err := x.Where("issue_id = ?", issueId).Where("comment_id = 0").Find(&attachments)
+	err := x.Where("issue_id = ?", issueId).And("comment_id = 0").Find(&attachments)
 	return attachments, err
 }
 
 // GetAttachmentsByIssue returns a list of attachments for the given issue
 func GetAttachmentsByIssue(issueId int64) ([]*Attachment, error) {
 	attachments := make([]*Attachment, 0, 10)
-	err := x.Where("issue_id = ?", issueId).Where("comment_id > 0").Find(&attachments)
+	err := x.Where("issue_id = ?", issueId).And("comment_id > 0").Find(&attachments)
 	return attachments, err
 }
 
diff --git a/routers/repo/issue.go b/routers/repo/issue.go
index abcf43a945..3a0a540e3b 100644
--- a/routers/repo/issue.go
+++ b/routers/repo/issue.go
@@ -1018,13 +1018,17 @@ func IssuePostAttachment(ctx *middleware.Context, params martini.Params) {
 	issueId, _ := base.StrTo(params["index"]).Int64()
 
 	if issueId == 0 {
-		ctx.Handle(400, "issue.IssuePostAttachment", nil)
+		ctx.JSON(400, map[string]interface{}{
+			"ok":    false,
+			"error": "invalid issue id",
+		})
+
 		return
 	}
 
-	commentId, err := base.StrTo(params["id"]).Int64()
+	commentId, err := base.StrTo(params["comment"]).Int64()
 
-	if err != nil && len(params["id"]) > 0 {
+	if err != nil && len(params["comment"]) > 0 {
 		ctx.JSON(400, map[string]interface{}{
 			"ok":    false,
 			"error": "invalid comment id",
@@ -1132,3 +1136,102 @@ func IssueGetAttachment(ctx *middleware.Context, params martini.Params) {
 
 	ctx.ServeFile(attachment.Path, attachment.Name)
 }
+
+func IssueDeleteAttachment(ctx *middleware.Context, params martini.Params) {
+	issueId, _ := base.StrTo(params["index"]).Int64()
+
+	if issueId == 0 {
+		ctx.JSON(400, map[string]interface{}{
+			"ok":    false,
+			"error": "invalid issue 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.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 != issueId {
+		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,
+	})
+}
diff --git a/templates/repo/issue/create.tmpl b/templates/repo/issue/create.tmpl
index b7f281c51d..3463d85ca8 100644
--- a/templates/repo/issue/create.tmpl
+++ b/templates/repo/issue/create.tmpl
@@ -101,13 +101,17 @@
                         <div class="tab-pane issue-preview-content" id="issue-preview">loading...</div>
                     </div>
                 </div>
+                <!--
                 <div>
                     <div id="attached"></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="hidden" value="id" name="repo-id"/>
                         <button class="btn-success btn">Create new issue</button>
diff --git a/templates/repo/issue/view.tmpl b/templates/repo/issue/view.tmpl
index 500d3ce3f6..8bcfe6b971 100644
--- a/templates/repo/issue/view.tmpl
+++ b/templates/repo/issue/view.tmpl
@@ -113,13 +113,17 @@
                                     <div class="tab-pane issue-preview-content" id="issue-preview">Loading...</div>
                                 </div>
                             </div>
+                            <!--
                             <div>
                                 <div id="attached"></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>
+                                    -->
 
                                     {{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}}

From 1129f756c89395befc8363884e7335ef2f51aa83 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Justin=20Nu=C3=9F?= <justin.nuss@hmmh.de>
Date: Thu, 24 Jul 2014 12:37:26 +0200
Subject: [PATCH 04/12] Fix missing . (dot) in issue templates

---
 templates/repo/issue/create.tmpl | 2 +-
 templates/repo/issue/view.tmpl   | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/templates/repo/issue/create.tmpl b/templates/repo/issue/create.tmpl
index 3463d85ca8..1146a4664f 100644
--- a/templates/repo/issue/create.tmpl
+++ b/templates/repo/issue/create.tmpl
@@ -110,7 +110,7 @@
                     <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>
+                        <button data-accept="{{.AllowedTypes}}" data-comment-id="0" class="btn-default btn attachment-add" id="attachments-button">Add Attachments...</button>
                         -->
 
                         <input type="hidden" value="id" name="repo-id"/>
diff --git a/templates/repo/issue/view.tmpl b/templates/repo/issue/view.tmpl
index 55f788bdba..a336205415 100644
--- a/templates/repo/issue/view.tmpl
+++ b/templates/repo/issue/view.tmpl
@@ -134,7 +134,7 @@
                                 <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>
+                                    <button data-accept="{{.AllowedTypes}}" class="btn-default btn attachment-add" id="attachments-button">Add Attachments...</button>
                                     -->
 
                                     {{if .IsIssueOwner}}{{if .Issue.IsClosed}}

From ef1cbcd761939e7730089fce9f5fbfdabe82c5bb Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Justin=20Nu=C3=9F?= <justin.nuss@hmmh.de>
Date: Thu, 24 Jul 2014 12:39:03 +0200
Subject: [PATCH 05/12] Allow admins to delete attachments.

---
 routers/repo/issue.go | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/routers/repo/issue.go b/routers/repo/issue.go
index c9d3112eaa..6becb2dff7 100644
--- a/routers/repo/issue.go
+++ b/routers/repo/issue.go
@@ -1158,7 +1158,7 @@ func IssueDeleteAttachment(ctx *middleware.Context, params martini.Params) {
 		return
 	}
 
-	if comment.PosterId != ctx.User.Id {
+	if comment.PosterId != ctx.User.Id && !ctx.User.IsAdmin {
 		ctx.JSON(400, map[string]interface{}{
 			"ok":    false,
 			"error": "no permissions",

From fa1db64ff014d6d06151431a3b1417ebf0104bde Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Justin=20Nu=C3=9F?= <justin.nuss@hmmh.de>
Date: Thu, 24 Jul 2014 13:49:27 +0200
Subject: [PATCH 06/12] Add files/ directory to .gitignore

---
 .gitignore | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/.gitignore b/.gitignore
index c7e41daee5..865f77c634 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,6 +7,7 @@ data/
 .idea/
 *.iml
 public/img/avatar/
+files/
 
 # Compiled Object files, Static and Dynamic libs (Shared Objects)
 *.o
@@ -34,4 +35,4 @@ _testmain.go
 gogs
 __pycache__
 *.pem
-output*
\ No newline at end of file
+output*

From 43e5de7f830a098582b519706f9c5da6eecd2c3e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Justin=20Nu=C3=9F?= <justin.nuss@hmmh.de>
Date: Thu, 24 Jul 2014 13:50:03 +0200
Subject: [PATCH 07/12] Show attachments in issues/comments and add preview for
 images

---
 cmd/web.go                     | 15 +++++-----
 public/css/gogs.css            | 25 ++++++++++++++++
 public/js/app.js               | 55 ++++++++++++++++++++++++++++++++++
 routers/repo/issue.go          | 46 +++++++++++++++++++++++-----
 templates/repo/issue/view.tmpl | 28 ++++++++++++-----
 5 files changed, 145 insertions(+), 24 deletions(-)

diff --git a/cmd/web.go b/cmd/web.go
index 0b7aac33b4..aea0cd86aa 100644
--- a/cmd/web.go
+++ b/cmd/web.go
@@ -238,14 +238,6 @@ func runWeb(*cli.Context) {
 			r.Post("/:index/label", repo.UpdateIssueLabel)
 			r.Post("/:index/milestone", repo.UpdateIssueMilestone)
 			r.Post("/:index/assignee", repo.UpdateAssignee)
-
-			m.Group("/: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)
-			})
-
 			r.Post("/labels/new", bindIgnErr(auth.CreateLabelForm{}), repo.NewLabel)
 			r.Post("/labels/edit", bindIgnErr(auth.CreateLabelForm{}), repo.UpdateLabel)
 			r.Post("/labels/delete", repo.DeleteLabel)
@@ -262,6 +254,13 @@ 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)
diff --git a/public/css/gogs.css b/public/css/gogs.css
index 710d0c20b1..e78d7f940c 100755
--- a/public/css/gogs.css
+++ b/public/css/gogs.css
@@ -1794,4 +1794,29 @@ body {
     color: #444;
     font-weight: bold;
     line-height: 30px;
+}
+
+.issue-main .attachments {
+    margin: 0px 10px 10px 10px;
+}
+
+.issue-main .attachments .attachment-label {
+    margin-right: 5px;
+}
+
+.attachment-preview {
+    position: absolute;
+    top: 0px;
+    bottom: 0px;
+    
+    margin: 5px;
+    padding: 8px;
+
+    background: #fff;
+    border: 1px solid #d8d8d8;
+    box-shadow: 0 0 5px 1px #d8d8d8;
+}
+
+.attachment-preview-img {
+    border: 1px solid #d8d8d8;
 }
\ No newline at end of file
diff --git a/public/js/app.js b/public/js/app.js
index 16d1d5dab1..3d4ed62362 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -520,6 +520,61 @@ function initIssue() {
         });
     }());
 
+    // Preview for images.
+    (function() {
+        var $hoverElement = $("<div></div>");
+        var $hoverImage = $("<img />");
+
+        $hoverElement.addClass("attachment-preview");
+        $hoverElement.hide();
+
+        $hoverImage.addClass("attachment-preview-img");
+
+        $hoverElement.append($hoverImage);
+        $(document.body).append($hoverElement); 
+
+        var over = function() {
+            var $this = $(this);
+
+            if ($this.text().match(/\.(png|jpg|jpeg|gif)$/) == false) {
+                return;
+            }
+
+            if ($hoverImage.attr("src") != $this.attr("href")) {
+                $hoverImage.attr("src", $this.attr("href"));
+                $hoverImage.load(function() {
+                    var height = this.height;
+                    var width = this.width;
+
+                    if (height > 300) {
+                        var factor = 300 / height;
+
+                        height = factor * height;
+                        width = factor * width;
+                    }
+
+                    $hoverImage.css({"height": height, "width": width});
+
+                    var offset = $this.offset();
+                    var left = offset.left, top = offset.top + $this.height() + 5;
+
+                    $hoverElement.css({"top": top + "px", "left": left + "px"});
+                    $hoverElement.css({"height": height + 16, "width": width + 16});
+                    $hoverElement.show();
+                });            
+            } else {
+                $hoverElement.show();
+            }
+        };
+
+        var out = function() {
+            $hoverElement.hide();
+        };
+
+        $(".issue-main .attachments .attachment").hover(over, out);
+    }());
+
+    // Upload.
     (function() {
         var $attached = $("#attached");
         var $attachments = $("input[name=attachments]");
diff --git a/routers/repo/issue.go b/routers/repo/issue.go
index 6becb2dff7..903a32d968 100644
--- a/routers/repo/issue.go
+++ b/routers/repo/issue.go
@@ -709,6 +709,12 @@ 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 {
@@ -1002,12 +1008,23 @@ func UpdateMilestonePost(ctx *middleware.Context, params martini.Params, form au
 }
 
 func IssuePostAttachment(ctx *middleware.Context, params martini.Params) {
-	issueId, _ := base.StrTo(params["index"]).Int64()
+	index, _ := base.StrTo(params["index"]).Int64()
 
-	if issueId == 0 {
+	if index == 0 {
 		ctx.JSON(400, map[string]interface{}{
 			"ok":    false,
-			"error": "invalid issue id",
+			"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
@@ -1089,7 +1106,7 @@ func IssuePostAttachment(ctx *middleware.Context, params martini.Params) {
 		return
 	}
 
-	a, err := models.CreateAttachment(issueId, commentId, header.Filename, out.Name())
+	a, err := models.CreateAttachment(issue.Id, commentId, header.Filename, out.Name())
 
 	if err != nil {
 		ctx.JSON(500, map[string]interface{}{
@@ -1121,16 +1138,29 @@ 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) {
-	issueId, _ := base.StrTo(params["index"]).Int64()
+	index, _ := base.StrTo(params["index"]).Int64()
 
-	if issueId == 0 {
+	if index == 0 {
 		ctx.JSON(400, map[string]interface{}{
 			"ok":    false,
-			"error": "invalid issue id",
+			"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
@@ -1189,7 +1219,7 @@ func IssueDeleteAttachment(ctx *middleware.Context, params martini.Params) {
 		return
 	}
 
-	if attachment.IssueId != issueId {
+	if attachment.IssueId != issue.Id {
 		ctx.JSON(400, map[string]interface{}{
 			"ok":    false,
 			"error": "attachment not associated with the given issue",
diff --git a/templates/repo/issue/view.tmpl b/templates/repo/issue/view.tmpl
index a336205415..8c90f312c3 100644
--- a/templates/repo/issue/view.tmpl
+++ b/templates/repo/issue/view.tmpl
@@ -45,13 +45,19 @@
                                         <div class="tab-pane issue-preview-content" id="issue-edit-preview">Loading...</div>
                                     </div>
                                 </div>
-                            </div>
-                            <div class="attachments">
-                                {{range .Attachments}}
-                                <a class="attachment" href="{{.IssueId}}/attachment/{{.Id}}">{{.Name}}</a>
-                                {{end}}
-                            </div>                            
+                            </div>                        
                         </div>
+                        {{with $attachments := .Issue.Attachments}}
+                        {{if $attachments}}
+                        <div class="attachments">
+                            <span class="attachment-label label label-info">Attachments:</span>
+                                
+                            {{range $attachments}}
+                            <a class="attachment label label-default" href="{{.IssueId}}/attachment/{{.Id}}">{{.Name}}</a>
+                            {{end}}
+                        </div>
+                        {{end}}
+                        {{end}}    
                     </div>
                     {{range .Comments}}
                     {{/* 0 = COMMENT, 1 = REOPEN, 2 = CLOSE, 3 = ISSUE, 4 = COMMIT, 5 = PULL */}}
@@ -68,11 +74,17 @@
                             <div class="panel-body markdown">
                                 {{str2html .Content}}
                             </div>
+                            {{with $attachments := .Attachments}}
+                            {{if $attachments}}
                             <div class="attachments">
-                                {{range .Attachments}}
-                                <a class="attachment" href="{{.IssueId}}/attachment/{{.Id}}">{{.Name}}</a>
+                                <span class="attachment-label label label-info">Attachments:</span>
+
+                                {{range $attachments}}
+                                <a class="attachment label label-default" href="{{.IssueId}}/attachment/{{.Id}}">{{.Name}}</a>
                                 {{end}}
                             </div>
+                            {{end}}
+                            {{end}}
                         </div>
                     </div>
                     {{else if eq .Type 1}}

From 70b0023b828b8dcd24914232741141e903532fff Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Justin=20Nu=C3=9F?= <justin.nuss@hmmh.de>
Date: Thu, 24 Jul 2014 13:58:39 +0200
Subject: [PATCH 08/12] Fix double decrement of issue counter

---
 models/action.go | 12 ------------
 1 file changed, 12 deletions(-)

diff --git a/models/action.go b/models/action.go
index 362b238f26..82d65de3fb 100644
--- a/models/action.go
+++ b/models/action.go
@@ -142,18 +142,6 @@ func updateIssuesCommit(userId, repoId int64, repoUserName, repoName string, com
 					return err
 				}
 
-				issue.Repo, err = GetRepositoryById(issue.RepoId)
-
-				if err != nil {
-					return err
-				}
-
-				issue.Repo.NumClosedIssues++
-
-				if err = UpdateRepository(issue.Repo); err != nil {
-					return err
-				}
-
 				if err = ChangeMilestoneIssueStats(issue); err != nil {
 					return err
 				}

From bfe5b86004791823b198be76084aa128d262b290 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Justin=20Nu=C3=9F?= <justin.nuss@hmmh.de>
Date: Thu, 24 Jul 2014 15:19:59 +0200
Subject: [PATCH 09/12] Add file upload for attachments

---
 cmd/web.go                       |   8 +-
 models/issue.go                  |  18 --
 modules/middleware/context.go    |  10 +-
 modules/setting/setting.go       |   4 +
 public/css/gogs.css              |  17 ++
 public/js/app.js                 |  27 ++-
 routers/repo/issue.go            | 321 +++++++------------------------
 templates/repo/issue/create.tmpl |  15 +-
 templates/repo/issue/view.tmpl   |  15 +-
 9 files changed, 132 insertions(+), 303 deletions(-)

diff --git a/cmd/web.go b/cmd/web.go
index aea0cd86aa..bb020fab90 100644
--- a/cmd/web.go
+++ b/cmd/web.go
@@ -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)
diff --git a/models/issue.go b/models/issue.go
index f16c384b6f..9422a7380d 100644
--- a/models/issue.go
+++ b/models/issue.go
@@ -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
-}
diff --git a/modules/middleware/context.go b/modules/middleware/context.go
index c641449a87..6b47e94fb0 100644
--- a/modules/middleware/context.go
+++ b/modules/middleware/context.go
@@ -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 + `">`)
diff --git a/modules/setting/setting.go b/modules/setting/setting.go
index ba9e86dc8f..349ef11595 100644
--- a/modules/setting/setting.go
+++ b/modules/setting/setting.go
@@ -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)
diff --git a/public/css/gogs.css b/public/css/gogs.css
index e78d7f940c..cc48f211f4 100755
--- a/public/css/gogs.css
+++ b/public/css/gogs.css
@@ -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;
 }
\ No newline at end of file
diff --git a/public/js/app.js b/public/js/app.js
index 3d4ed62362..7ffcbd4a3e 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -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;
         });
     }());
diff --git a/routers/repo/issue.go b/routers/repo/issue.go
index 903a32d968..81261e6cd5 100644
--- a/routers/repo/issue.go
+++ b/routers/repo/issue.go
@@ -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,
-	})
-}
diff --git a/templates/repo/issue/create.tmpl b/templates/repo/issue/create.tmpl
index 1146a4664f..0d72123b81 100644
--- a/templates/repo/issue/create.tmpl
+++ b/templates/repo/issue/create.tmpl
@@ -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>
diff --git a/templates/repo/issue/view.tmpl b/templates/repo/issue/view.tmpl
index 8c90f312c3..dd200e8016 100644
--- a/templates/repo/issue/view.tmpl
+++ b/templates/repo/issue/view.tmpl
@@ -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}}&nbsp;&nbsp;

From 9df99681fbcb5a809d2821c3861c8655f885af7a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Justin=20Nu=C3=9F?= <justin.nuss@hmmh.de>
Date: Thu, 24 Jul 2014 15:23:56 +0200
Subject: [PATCH 10/12] Update default config with comments

---
 conf/app.ini | 5 +++++
 1 file changed, 5 insertions(+)

diff --git a/conf/app.ini b/conf/app.ini
index 3cea1fdd0f..20ff349441 100644
--- a/conf/app.ini
+++ b/conf/app.ini
@@ -181,9 +181,14 @@ SERVICE = server
 DISABLE_GRAVATAR = false
 
 [attachment]
+; Path for attachments. Defaults to files/attachments
 PATH = 
 ; One or more allowed types, e.g. image/jpeg|image/png
 ALLOWED_TYPES = 
+; Max size of each file. Defaults to 32MB
+MAX_SIZE
+; Max number of files per upload. Defaults to 10
+MAX_FILES =
 
 [log]
 ROOT_PATH =

From 4d702eb345d36888b9cace32ba51ac79e9a260f8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Justin=20Nu=C3=9F?= <justin.nuss@hmmh.de>
Date: Thu, 24 Jul 2014 15:51:40 +0200
Subject: [PATCH 11/12] Allow disabling uploads

---
 conf/app.ini                     |  2 ++
 modules/setting/setting.go       |  2 ++
 routers/repo/issue.go            | 12 +++++++++++-
 templates/repo/issue/create.tmpl |  4 ++++
 templates/repo/issue/view.tmpl   |  4 ++++
 5 files changed, 23 insertions(+), 1 deletion(-)

diff --git a/conf/app.ini b/conf/app.ini
index 20ff349441..96e320375b 100644
--- a/conf/app.ini
+++ b/conf/app.ini
@@ -181,6 +181,8 @@ SERVICE = server
 DISABLE_GRAVATAR = false
 
 [attachment]
+; Whether attachments are enabled. Defaults to `true`
+ENABLE =
 ; Path for attachments. Defaults to files/attachments
 PATH = 
 ; One or more allowed types, e.g. image/jpeg|image/png
diff --git a/modules/setting/setting.go b/modules/setting/setting.go
index 349ef11595..569d1bd1c5 100644
--- a/modules/setting/setting.go
+++ b/modules/setting/setting.go
@@ -76,6 +76,7 @@ var (
 	AttachmentAllowedTypes string
 	AttachmentMaxSize      int64
 	AttachmentMaxFiles     int
+	AttachmentEnabled      bool
 
 	// Cache settings.
 	Cache        cache.Cache
@@ -176,6 +177,7 @@ func NewConfigContext() {
 	AttachmentAllowedTypes = Cfg.MustValue("attachment", "ALLOWED_TYPES", "*/*")
 	AttachmentMaxSize = Cfg.MustInt64("attachment", "MAX_SIZE", 32)
 	AttachmentMaxFiles = Cfg.MustInt("attachment", "MAX_FILES", 10)
+	AttachmentEnabled = Cfg.MustBool("attachment", "ENABLE", true)
 
 	if err = os.MkdirAll(AttachmentPath, os.ModePerm); err != nil {
 		log.Fatal("Could not create directory %s: %s", AttachmentPath, err)
diff --git a/routers/repo/issue.go b/routers/repo/issue.go
index 81261e6cd5..c033e0f31c 100644
--- a/routers/repo/issue.go
+++ b/routers/repo/issue.go
@@ -160,6 +160,7 @@ func CreateIssue(ctx *middleware.Context, params martini.Params) {
 	ctx.Data["Title"] = "Create issue"
 	ctx.Data["IsRepoToolbarIssues"] = true
 	ctx.Data["IsRepoToolbarIssuesList"] = false
+	ctx.Data["AttachmentsEnabled"] = setting.AttachmentEnabled
 
 	var err error
 	// Get all milestones.
@@ -190,6 +191,7 @@ func CreateIssuePost(ctx *middleware.Context, params martini.Params, form auth.C
 	ctx.Data["Title"] = "Create issue"
 	ctx.Data["IsRepoToolbarIssues"] = true
 	ctx.Data["IsRepoToolbarIssuesList"] = false
+	ctx.Data["AttachmentsEnabled"] = setting.AttachmentEnabled
 
 	var err error
 	// Get all milestones.
@@ -239,7 +241,9 @@ func CreateIssuePost(ctx *middleware.Context, params martini.Params, form auth.C
 		return
 	}
 
-	uploadFiles(ctx, issue.Id, 0)
+	if setting.AttachmentEnabled {
+		uploadFiles(ctx, issue.Id, 0)
+	}
 
 	// Update mentions.
 	ms := base.MentionPattern.FindAllString(issue.Content, -1)
@@ -313,6 +317,8 @@ func checkLabels(labels, allLabels []*models.Label) {
 }
 
 func ViewIssue(ctx *middleware.Context, params martini.Params) {
+	ctx.Data["AttachmentsEnabled"] = setting.AttachmentEnabled
+
 	idx, _ := base.StrTo(params["index"]).Int64()
 	if idx == 0 {
 		ctx.Handle(404, "issue.ViewIssue", nil)
@@ -628,6 +634,10 @@ func UpdateAssignee(ctx *middleware.Context) {
 }
 
 func uploadFiles(ctx *middleware.Context, issueId, commentId int64) {
+	if !setting.AttachmentEnabled {
+		return
+	}
+
 	allowedTypes := strings.Split(setting.AttachmentAllowedTypes, "|")
 	attachments := ctx.Req.MultipartForm.File["attachments"]
 
diff --git a/templates/repo/issue/create.tmpl b/templates/repo/issue/create.tmpl
index 0d72123b81..7705841708 100644
--- a/templates/repo/issue/create.tmpl
+++ b/templates/repo/issue/create.tmpl
@@ -101,13 +101,17 @@
                         <div class="tab-pane issue-preview-content" id="issue-preview">loading...</div>
                     </div>
                 </div>
+                {{if .AttachmentsEnabled}}
                 <div id="attached">
                     <div id="attached-list"></div>
                 </div>
+                {{end}}
                 <div class="text-right panel-body">
                     <div class="form-group">
+                        {{if .AttachmentsEnabled}}
                         <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>
+                        {{end}}
                         <input type="hidden" value="id" name="repo-id"/>
                         <button class="btn-success btn">Create new issue</button>
                     </div>
diff --git a/templates/repo/issue/view.tmpl b/templates/repo/issue/view.tmpl
index dd200e8016..570698975b 100644
--- a/templates/repo/issue/view.tmpl
+++ b/templates/repo/issue/view.tmpl
@@ -137,13 +137,17 @@
                                     <div class="tab-pane issue-preview-content" id="issue-preview">Loading...</div>
                                 </div>
                             </div>
+                            {{if .AttachmentsEnabled}}
                             <div id="attached">
                                 <div id="attached-list"></div>
                             </div>
+                            {{end}}
                             <div class="text-right">
                                 <div class="form-group">
+                                    {{if .AttachmentsEnabled}}
                                     <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>
+                                    {{end}}
                                     {{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}}&nbsp;&nbsp;

From 9a7349ce64afdbc528126862283b1068d4c8699a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Justin=20Nu=C3=9F?= <nuss.justin@gmail.com>
Date: Thu, 24 Jul 2014 17:02:42 +0200
Subject: [PATCH 12/12] Change Attachment.Path to TEXT (no maximum size)

---
 models/issue.go | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/models/issue.go b/models/issue.go
index 9422a7380d..05c9525341 100644
--- a/models/issue.go
+++ b/models/issue.go
@@ -981,7 +981,7 @@ type Attachment struct {
 	IssueId   int64
 	CommentId int64
 	Name      string
-	Path      string
+	Path      string    `xorm:"TEXT"`
 	Created   time.Time `xorm:"CREATED"`
 }