@@ -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) |
@@ -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" |
@@ -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 | |||
} |
@@ -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() { |
@@ -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 { |
@@ -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 () { |
@@ -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) | |||
} |
@@ -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}} |