@@ -7,6 +7,7 @@ data/ | |||||
.idea/ | .idea/ | ||||
*.iml | *.iml | ||||
public/img/avatar/ | public/img/avatar/ | ||||
files/ | |||||
# Compiled Object files, Static and Dynamic libs (Shared Objects) | # Compiled Object files, Static and Dynamic libs (Shared Objects) | ||||
*.o | *.o | ||||
@@ -34,4 +35,4 @@ _testmain.go | |||||
gogs | gogs | ||||
__pycache__ | __pycache__ | ||||
*.pem | *.pem | ||||
output* | |||||
output* |
@@ -238,6 +238,7 @@ func runWeb(*cli.Context) { | |||||
r.Post("/:index/label", repo.UpdateIssueLabel) | r.Post("/:index/label", repo.UpdateIssueLabel) | ||||
r.Post("/:index/milestone", repo.UpdateIssueMilestone) | r.Post("/:index/milestone", repo.UpdateIssueMilestone) | ||||
r.Post("/:index/assignee", repo.UpdateAssignee) | 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/new", bindIgnErr(auth.CreateLabelForm{}), repo.NewLabel) | ||||
r.Post("/labels/edit", bindIgnErr(auth.CreateLabelForm{}), repo.UpdateLabel) | r.Post("/labels/edit", bindIgnErr(auth.CreateLabelForm{}), repo.UpdateLabel) | ||||
r.Post("/labels/delete", repo.DeleteLabel) | r.Post("/labels/delete", repo.DeleteLabel) | ||||
@@ -180,6 +180,18 @@ SESSION_ID_HASHKEY = | |||||
SERVICE = server | SERVICE = server | ||||
DISABLE_GRAVATAR = false | 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 | |||||
ALLOWED_TYPES = | |||||
; Max size of each file. Defaults to 32MB | |||||
MAX_SIZE | |||||
; Max number of files per upload. Defaults to 10 | |||||
MAX_FILES = | |||||
[log] | [log] | ||||
ROOT_PATH = | ROOT_PATH = | ||||
; Either "console", "file", "conn", "smtp" or "database", default is "console" | ; Either "console", "file", "conn", "smtp" or "database", default is "console" | ||||
@@ -127,7 +127,7 @@ func updateIssuesCommit(userId, repoId int64, repoUserName, repoName string, com | |||||
url := fmt.Sprintf("/%s/%s/commit/%s", repoUserName, repoName, c.Sha1) | url := fmt.Sprintf("/%s/%s/commit/%s", repoUserName, repoName, c.Sha1) | ||||
message := fmt.Sprintf(`<a href="%s">%s</a>`, url, c.Message) | message := fmt.Sprintf(`<a href="%s">%s</a>`, url, c.Message) | ||||
if err = CreateComment(userId, issue.RepoId, issue.Id, 0, 0, COMMIT, message); err != nil { | |||||
if _, err = CreateComment(userId, issue.RepoId, issue.Id, 0, 0, COMMIT, message, nil); err != nil { | |||||
return err | return err | ||||
} | } | ||||
@@ -142,24 +142,12 @@ func updateIssuesCommit(userId, repoId int64, repoUserName, repoName string, com | |||||
return err | 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 { | if err = ChangeMilestoneIssueStats(issue); err != nil { | ||||
return err | return err | ||||
} | } | ||||
// If commit happened in the referenced repository, it means the issue can be closed. | // If commit happened in the referenced repository, it means the issue can be closed. | ||||
if err = CreateComment(userId, repoId, issue.Id, 0, 0, CLOSE, ""); err != nil { | |||||
if _, err = CreateComment(userId, repoId, issue.Id, 0, 0, CLOSE, "", nil); err != nil { | |||||
return err | return err | ||||
} | } | ||||
} | } | ||||
@@ -8,6 +8,7 @@ import ( | |||||
"bytes" | "bytes" | ||||
"errors" | "errors" | ||||
"html/template" | "html/template" | ||||
"os" | |||||
"strconv" | "strconv" | ||||
"strings" | "strings" | ||||
"time" | "time" | ||||
@@ -15,14 +16,17 @@ import ( | |||||
"github.com/go-xorm/xorm" | "github.com/go-xorm/xorm" | ||||
"github.com/gogits/gogs/modules/base" | "github.com/gogits/gogs/modules/base" | ||||
"github.com/gogits/gogs/modules/log" | |||||
) | ) | ||||
var ( | 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") | |||||
ErrMissingIssueNumber = errors.New("No issue number specified") | |||||
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") | |||||
ErrMissingIssueNumber = errors.New("No issue number specified") | |||||
) | ) | ||||
// Issue represents an issue or pull request of repository. | // Issue represents an issue or pull request of repository. | ||||
@@ -94,6 +98,19 @@ func (i *Issue) GetAssignee() (err error) { | |||||
return err | return err | ||||
} | } | ||||
func (i *Issue) Attachments() []*Attachment { | |||||
a, _ := GetAttachmentsForIssue(i.Id) | |||||
return a | |||||
} | |||||
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. | // CreateIssue creates new issue for repository. | ||||
func NewIssue(issue *Issue) (err error) { | func NewIssue(issue *Issue) (err error) { | ||||
sess := x.NewSession() | sess := x.NewSession() | ||||
@@ -871,17 +888,19 @@ type Comment struct { | |||||
} | } | ||||
// CreateComment creates comment of issue or commit. | // CreateComment creates comment of issue or commit. | ||||
func CreateComment(userId, repoId, issueId, commitId, line int64, cmtType CommentType, content string) error { | |||||
func CreateComment(userId, repoId, issueId, commitId, line int64, cmtType CommentType, content string, attachments []int64) (*Comment, error) { | |||||
sess := x.NewSession() | sess := x.NewSession() | ||||
defer sess.Close() | defer sess.Close() | ||||
if err := sess.Begin(); err != nil { | 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() | sess.Rollback() | ||||
return err | |||||
return nil, err | |||||
} | } | ||||
// Check comment type. | // Check comment type. | ||||
@@ -890,22 +909,46 @@ func CreateComment(userId, repoId, issueId, commitId, line int64, cmtType Commen | |||||
rawSql := "UPDATE `issue` SET num_comments = num_comments + 1 WHERE id = ?" | rawSql := "UPDATE `issue` SET num_comments = num_comments + 1 WHERE id = ?" | ||||
if _, err := sess.Exec(rawSql, issueId); err != nil { | if _, err := sess.Exec(rawSql, issueId); err != nil { | ||||
sess.Rollback() | 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 REOPEN: | case REOPEN: | ||||
rawSql := "UPDATE `repository` SET num_closed_issues = num_closed_issues - 1 WHERE id = ?" | rawSql := "UPDATE `repository` SET num_closed_issues = num_closed_issues - 1 WHERE id = ?" | ||||
if _, err := sess.Exec(rawSql, repoId); err != nil { | if _, err := sess.Exec(rawSql, repoId); err != nil { | ||||
sess.Rollback() | sess.Rollback() | ||||
return err | |||||
return nil, err | |||||
} | } | ||||
case CLOSE: | case CLOSE: | ||||
rawSql := "UPDATE `repository` SET num_closed_issues = num_closed_issues + 1 WHERE id = ?" | rawSql := "UPDATE `repository` SET num_closed_issues = num_closed_issues + 1 WHERE id = ?" | ||||
if _, err := sess.Exec(rawSql, repoId); err != nil { | if _, err := sess.Exec(rawSql, repoId); err != nil { | ||||
sess.Rollback() | sess.Rollback() | ||||
return err | |||||
return nil, err | |||||
} | } | ||||
} | } | ||||
return sess.Commit() | |||||
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 | |||||
} | } | ||||
func (c *Comment) ContentHtml() template.HTML { | func (c *Comment) ContentHtml() template.HTML { | ||||
@@ -918,3 +961,127 @@ func GetIssueComments(issueId int64) ([]Comment, error) { | |||||
err := x.Asc("created").Find(&comments, &Comment{IssueId: issueId}) | err := x.Asc("created").Find(&comments, &Comment{IssueId: issueId}) | ||||
return comments, err | return comments, err | ||||
} | } | ||||
// Attachments returns the attachments for this comment. | |||||
func (c *Comment) Attachments() []*Attachment { | |||||
a, _ := GetAttachmentsByComment(c.Id) | |||||
return a | |||||
} | |||||
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 `xorm:"TEXT"` | |||||
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 | |||||
} | |||||
func GetAttachmentsForIssue(issueId int64) ([]*Attachment, error) { | |||||
attachments := make([]*Attachment, 0, 10) | |||||
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).And("comment_id > 0").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) | |||||
} |
@@ -36,7 +36,7 @@ func init() { | |||||
new(Action), new(Access), new(Issue), new(Comment), new(Oauth2), new(Follow), | new(Action), new(Access), new(Issue), new(Comment), new(Oauth2), new(Follow), | ||||
new(Mirror), new(Release), new(LoginSource), new(Webhook), new(IssueUser), | new(Mirror), new(Release), new(LoginSource), new(Webhook), new(IssueUser), | ||||
new(Milestone), new(Label), new(HookTask), new(Team), new(OrgUser), new(TeamUser), | new(Milestone), new(Label), new(HookTask), new(Team), new(OrgUser), new(TeamUser), | ||||
new(UpdateTask)) | |||||
new(UpdateTask), new(Attachment)) | |||||
} | } | ||||
func LoadModelsConfig() { | func LoadModelsConfig() { | ||||
@@ -319,7 +319,6 @@ func (f *Flash) Success(msg string) { | |||||
// InitContext initializes a classic context for a request. | // InitContext initializes a classic context for a request. | ||||
func InitContext() martini.Handler { | func InitContext() martini.Handler { | ||||
return func(res http.ResponseWriter, r *http.Request, c martini.Context, rd *Render) { | return func(res http.ResponseWriter, r *http.Request, c martini.Context, rd *Render) { | ||||
ctx := &Context{ | ctx := &Context{ | ||||
c: c, | c: c, | ||||
// p: p, | // p: p, | ||||
@@ -328,7 +327,6 @@ func InitContext() martini.Handler { | |||||
Cache: setting.Cache, | Cache: setting.Cache, | ||||
Render: rd, | Render: rd, | ||||
} | } | ||||
ctx.Data["PageStartTime"] = time.Now() | ctx.Data["PageStartTime"] = time.Now() | ||||
// start session | // start session | ||||
@@ -370,6 +368,14 @@ func InitContext() martini.Handler { | |||||
ctx.Data["IsAdmin"] = ctx.User.IsAdmin | 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 | // get or create csrf token | ||||
ctx.Data["CsrfToken"] = ctx.CsrfToken() | ctx.Data["CsrfToken"] = ctx.CsrfToken() | ||||
ctx.Data["CsrfTokenHtml"] = template.HTML(`<input type="hidden" name="_csrf" value="` + ctx.csrfToken + `">`) | ctx.Data["CsrfTokenHtml"] = template.HTML(`<input type="hidden" name="_csrf" value="` + ctx.csrfToken + `">`) | ||||
@@ -71,6 +71,13 @@ var ( | |||||
LogModes []string | LogModes []string | ||||
LogConfigs []string | LogConfigs []string | ||||
// Attachment settings. | |||||
AttachmentPath string | |||||
AttachmentAllowedTypes string | |||||
AttachmentMaxSize int64 | |||||
AttachmentMaxFiles int | |||||
AttachmentEnabled bool | |||||
// Cache settings. | // Cache settings. | ||||
Cache cache.Cache | Cache cache.Cache | ||||
CacheAdapter string | CacheAdapter string | ||||
@@ -166,6 +173,16 @@ func NewConfigContext() { | |||||
CookieRememberName = Cfg.MustValue("security", "COOKIE_REMEMBER_NAME") | CookieRememberName = Cfg.MustValue("security", "COOKIE_REMEMBER_NAME") | ||||
ReverseProxyAuthUser = Cfg.MustValue("security", "REVERSE_PROXY_AUTHENTICATION_USER", "X-WEBAUTH-USER") | ReverseProxyAuthUser = Cfg.MustValue("security", "REVERSE_PROXY_AUTHENTICATION_USER", "X-WEBAUTH-USER") | ||||
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) | |||||
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) | |||||
} | |||||
RunUser = Cfg.MustValue("", "RUN_USER") | RunUser = Cfg.MustValue("", "RUN_USER") | ||||
curUser := os.Getenv("USER") | curUser := os.Getenv("USER") | ||||
if len(curUser) == 0 { | if len(curUser) == 0 { | ||||
@@ -1794,4 +1794,46 @@ body { | |||||
color: #444; | color: #444; | ||||
font-weight: bold; | font-weight: bold; | ||||
line-height: 30px; | 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; | |||||
} | |||||
#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; | |||||
} | } |
@@ -520,6 +520,90 @@ 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)$/i) == 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 $attachedList = $("#attached-list"); | |||||
var $addButton = $("#attachments-button"); | |||||
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() { | |||||
fileInput.click(); | |||||
return false; | |||||
}); | |||||
}()); | |||||
// issue edit mode | // issue edit mode | ||||
(function () { | (function () { | ||||
$("#issue-edit-btn").on("click", function () { | $("#issue-edit-btn").on("click", function () { | ||||
@@ -5,7 +5,11 @@ | |||||
package repo | package repo | ||||
import ( | import ( | ||||
"errors" | |||||
"fmt" | "fmt" | ||||
"io" | |||||
"io/ioutil" | |||||
"mime" | |||||
"net/url" | "net/url" | ||||
"strings" | "strings" | ||||
"time" | "time" | ||||
@@ -32,6 +36,11 @@ const ( | |||||
MILESTONE_EDIT base.TplName = "repo/issue/milestone_edit" | 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) { | func Issues(ctx *middleware.Context) { | ||||
ctx.Data["Title"] = "Issues" | ctx.Data["Title"] = "Issues" | ||||
ctx.Data["IsRepoToolbarIssues"] = true | ctx.Data["IsRepoToolbarIssues"] = true | ||||
@@ -151,6 +160,7 @@ func CreateIssue(ctx *middleware.Context, params martini.Params) { | |||||
ctx.Data["Title"] = "Create issue" | ctx.Data["Title"] = "Create issue" | ||||
ctx.Data["IsRepoToolbarIssues"] = true | ctx.Data["IsRepoToolbarIssues"] = true | ||||
ctx.Data["IsRepoToolbarIssuesList"] = false | ctx.Data["IsRepoToolbarIssuesList"] = false | ||||
ctx.Data["AttachmentsEnabled"] = setting.AttachmentEnabled | |||||
var err error | var err error | ||||
// Get all milestones. | // Get all milestones. | ||||
@@ -170,7 +180,10 @@ func CreateIssue(ctx *middleware.Context, params martini.Params) { | |||||
ctx.Handle(500, "issue.CreateIssue(GetCollaborators)", err) | ctx.Handle(500, "issue.CreateIssue(GetCollaborators)", err) | ||||
return | return | ||||
} | } | ||||
ctx.Data["AllowedTypes"] = setting.AttachmentAllowedTypes | |||||
ctx.Data["Collaborators"] = us | ctx.Data["Collaborators"] = us | ||||
ctx.HTML(200, ISSUE_CREATE) | ctx.HTML(200, ISSUE_CREATE) | ||||
} | } | ||||
@@ -178,6 +191,7 @@ func CreateIssuePost(ctx *middleware.Context, params martini.Params, form auth.C | |||||
ctx.Data["Title"] = "Create issue" | ctx.Data["Title"] = "Create issue" | ||||
ctx.Data["IsRepoToolbarIssues"] = true | ctx.Data["IsRepoToolbarIssues"] = true | ||||
ctx.Data["IsRepoToolbarIssuesList"] = false | ctx.Data["IsRepoToolbarIssuesList"] = false | ||||
ctx.Data["AttachmentsEnabled"] = setting.AttachmentEnabled | |||||
var err error | var err error | ||||
// Get all milestones. | // Get all milestones. | ||||
@@ -227,6 +241,10 @@ func CreateIssuePost(ctx *middleware.Context, params martini.Params, form auth.C | |||||
return | return | ||||
} | } | ||||
if setting.AttachmentEnabled { | |||||
uploadFiles(ctx, issue.Id, 0) | |||||
} | |||||
// Update mentions. | // Update mentions. | ||||
ms := base.MentionPattern.FindAllString(issue.Content, -1) | ms := base.MentionPattern.FindAllString(issue.Content, -1) | ||||
if len(ms) > 0 { | if len(ms) > 0 { | ||||
@@ -299,6 +317,8 @@ func checkLabels(labels, allLabels []*models.Label) { | |||||
} | } | ||||
func ViewIssue(ctx *middleware.Context, params martini.Params) { | func ViewIssue(ctx *middleware.Context, params martini.Params) { | ||||
ctx.Data["AttachmentsEnabled"] = setting.AttachmentEnabled | |||||
idx, _ := base.StrTo(params["index"]).Int64() | idx, _ := base.StrTo(params["index"]).Int64() | ||||
if idx == 0 { | if idx == 0 { | ||||
ctx.Handle(404, "issue.ViewIssue", nil) | ctx.Handle(404, "issue.ViewIssue", nil) | ||||
@@ -399,6 +419,8 @@ func ViewIssue(ctx *middleware.Context, params martini.Params) { | |||||
} | } | ||||
} | } | ||||
ctx.Data["AllowedTypes"] = setting.AttachmentAllowedTypes | |||||
ctx.Data["Title"] = issue.Name | ctx.Data["Title"] = issue.Name | ||||
ctx.Data["Issue"] = issue | ctx.Data["Issue"] = issue | ||||
ctx.Data["Comments"] = comments | ctx.Data["Comments"] = comments | ||||
@@ -611,6 +633,71 @@ 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"] | |||||
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) { | func Comment(ctx *middleware.Context, params martini.Params) { | ||||
index, err := base.StrTo(ctx.Query("issueIndex")).Int64() | index, err := base.StrTo(ctx.Query("issueIndex")).Int64() | ||||
if err != nil { | if err != nil { | ||||
@@ -657,7 +744,7 @@ func Comment(ctx *middleware.Context, params martini.Params) { | |||||
cmtType = models.REOPEN | cmtType = models.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) | ctx.Handle(200, "issue.Comment(create status change comment)", err) | ||||
return | return | ||||
} | } | ||||
@@ -665,12 +752,14 @@ func Comment(ctx *middleware.Context, params martini.Params) { | |||||
} | } | ||||
} | } | ||||
var comment *models.Comment | |||||
var ms []string | var ms []string | ||||
content := ctx.Query("content") | content := ctx.Query("content") | ||||
if len(content) > 0 { | if len(content) > 0 { | ||||
switch params["action"] { | switch params["action"] { | ||||
case "new": | case "new": | ||||
if err = models.CreateComment(ctx.User.Id, ctx.Repo.Repository.Id, issue.Id, 0, 0, models.COMMENT, content); err != nil { | |||||
if comment, err = models.CreateComment(ctx.User.Id, ctx.Repo.Repository.Id, issue.Id, 0, 0, models.COMMENT, content, nil); err != nil { | |||||
ctx.Handle(500, "issue.Comment(create comment)", err) | ctx.Handle(500, "issue.Comment(create comment)", err) | ||||
return | return | ||||
} | } | ||||
@@ -696,6 +785,10 @@ func Comment(ctx *middleware.Context, params martini.Params) { | |||||
} | } | ||||
} | } | ||||
if comment != nil { | |||||
uploadFiles(ctx, issue.Id, comment.Id) | |||||
} | |||||
// Notify watchers. | // Notify watchers. | ||||
act := &models.Action{ | act := &models.Action{ | ||||
ActUserId: ctx.User.Id, | ActUserId: ctx.User.Id, | ||||
@@ -972,3 +1065,21 @@ func UpdateMilestonePost(ctx *middleware.Context, params martini.Params, form au | |||||
ctx.Redirect(ctx.Repo.RepoLink + "/issues/milestones") | ctx.Redirect(ctx.Repo.RepoLink + "/issues/milestones") | ||||
} | } | ||||
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) | |||||
} |
@@ -4,7 +4,7 @@ | |||||
{{template "repo/toolbar" .}} | {{template "repo/toolbar" .}} | ||||
<div id="body" class="container"> | <div id="body" class="container"> | ||||
<div id="issue"> | <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}} | {{.CsrfTokenHtml}} | ||||
{{template "base/alert" .}} | {{template "base/alert" .}} | ||||
<div class="col-md-1"> | <div class="col-md-1"> | ||||
@@ -101,8 +101,17 @@ | |||||
<div class="tab-pane issue-preview-content" id="issue-preview">loading...</div> | <div class="tab-pane issue-preview-content" id="issue-preview">loading...</div> | ||||
</div> | </div> | ||||
</div> | </div> | ||||
{{if .AttachmentsEnabled}} | |||||
<div id="attached"> | |||||
<div id="attached-list"></div> | |||||
</div> | |||||
{{end}} | |||||
<div class="text-right panel-body"> | <div class="text-right panel-body"> | ||||
<div class="form-group"> | <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"/> | <input type="hidden" value="id" name="repo-id"/> | ||||
<button class="btn-success btn">Create new issue</button> | <button class="btn-success btn">Create new issue</button> | ||||
</div> | </div> | ||||
@@ -45,8 +45,19 @@ | |||||
<div class="tab-pane issue-preview-content" id="issue-edit-preview">Loading...</div> | <div class="tab-pane issue-preview-content" id="issue-edit-preview">Loading...</div> | ||||
</div> | </div> | ||||
</div> | </div> | ||||
</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> | </div> | ||||
{{end}} | |||||
{{end}} | |||||
</div> | </div> | ||||
{{range .Comments}} | {{range .Comments}} | ||||
{{/* 0 = COMMENT, 1 = REOPEN, 2 = CLOSE, 3 = ISSUE, 4 = COMMIT, 5 = PULL */}} | {{/* 0 = COMMENT, 1 = REOPEN, 2 = CLOSE, 3 = ISSUE, 4 = COMMIT, 5 = PULL */}} | ||||
@@ -63,6 +74,17 @@ | |||||
<div class="panel-body markdown"> | <div class="panel-body markdown"> | ||||
{{str2html .Content}} | {{str2html .Content}} | ||||
</div> | </div> | ||||
{{with $attachments := .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> | </div> | ||||
</div> | </div> | ||||
{{else if eq .Type 1}} | {{else if eq .Type 1}} | ||||
@@ -95,7 +117,7 @@ | |||||
<hr class="issue-line"/> | <hr class="issue-line"/> | ||||
{{if .SignedUser}}<div class="issue-child issue-reply"> | {{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> | <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}} | {{.CsrfTokenHtml}} | ||||
<div class="panel-body"> | <div class="panel-body"> | ||||
<div class="form-group"> | <div class="form-group"> | ||||
@@ -115,8 +137,17 @@ | |||||
<div class="tab-pane issue-preview-content" id="issue-preview">Loading...</div> | <div class="tab-pane issue-preview-content" id="issue-preview">Loading...</div> | ||||
</div> | </div> | ||||
</div> | </div> | ||||
{{if .AttachmentsEnabled}} | |||||
<div id="attached"> | |||||
<div id="attached-list"></div> | |||||
</div> | |||||
{{end}} | |||||
<div class="text-right"> | <div class="text-right"> | ||||
<div class="form-group"> | <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}} | {{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-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}} | <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}} | ||||