WIP: Allow attachments for comments

This commit is contained in:
Justin Nuß 2014-07-23 21:15:47 +02:00
parent 6e9f1c52b1
commit 4617bef895
8 changed files with 366 additions and 17 deletions

View File

@ -238,6 +238,9 @@ 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.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/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)

View File

@ -180,6 +180,11 @@ SESSION_ID_HASHKEY =
SERVICE = server SERVICE = server
DISABLE_GRAVATAR = false DISABLE_GRAVATAR = false
[attachment]
PATH =
; One or more allowed types, e.g. image/jpeg|image/png
ALLOWED_TYPES =
[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"

View File

@ -7,12 +7,15 @@ package models
import ( import (
"bytes" "bytes"
"errors" "errors"
"os"
"strconv"
"strings" "strings"
"time" "time"
"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 (
@ -20,6 +23,8 @@ var (
ErrLabelNotExist = errors.New("Label does not exist") ErrLabelNotExist = errors.New("Label does not exist")
ErrMilestoneNotExist = errors.New("Milestone does not exist") ErrMilestoneNotExist = errors.New("Milestone does not exist")
ErrWrongIssueCounter = errors.New("Invalid number of issues for this milestone") 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. // Issue represents an issue or pull request of repository.
@ -91,6 +96,14 @@ func (i *Issue) GetAssignee() (err error) {
return err 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. // CreateIssue creates new issue for repository.
func NewIssue(issue *Issue) (err error) { func NewIssue(issue *Issue) (err error) {
sess := x.NewSession() sess := x.NewSession()
@ -795,17 +808,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 int, content string) error { func CreateComment(userId, repoId, issueId, commitId, line int64, cmtType int, 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, comment := &Comment{PosterId: userId, Type: cmtType, IssueId: issueId,
CommitId: commitId, Line: line, Content: content}); err != nil { 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.
@ -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 = ?" 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 IT_REOPEN: case IT_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 IT_CLOSE: case IT_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()
} }
// GetIssueComments returns list of comment by given issue id. // 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}) 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, 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
}

View File

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

View File

@ -71,6 +71,10 @@ var (
LogModes []string LogModes []string
LogConfigs []string LogConfigs []string
// Attachment settings.
AttachmentPath string
AttachmentAllowedTypes string
// Cache settings. // Cache settings.
Cache cache.Cache Cache cache.Cache
CacheAdapter string CacheAdapter string
@ -166,6 +170,13 @@ 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", "*/*")
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 {

View File

@ -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 // issue edit mode
(function () { (function () {
$("#issue-edit-btn").on("click", function () { $("#issue-edit-btn").on("click", function () {

View File

@ -6,6 +6,9 @@ package repo
import ( import (
"fmt" "fmt"
"io"
"io/ioutil"
"mime"
"net/url" "net/url"
"strings" "strings"
"time" "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)) 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["Title"] = issue.Name
ctx.Data["Issue"] = issue ctx.Data["Issue"] = issue
ctx.Data["Comments"] = comments ctx.Data["Comments"] = comments
@ -670,7 +675,7 @@ func Comment(ctx *middleware.Context, params martini.Params) {
cmtType = models.IT_REOPEN 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) ctx.Handle(200, "issue.Comment(create status change comment)", err)
return return
} }
@ -678,12 +683,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.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) ctx.Handle(500, "issue.Comment(create comment)", err)
return 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. // Notify watchers.
act := &models.Action{ act := &models.Action{
ActUserId: ctx.User.Id, 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") 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)
}

View File

@ -62,6 +62,11 @@
<div class="panel-body markdown"> <div class="panel-body markdown">
{{str2html .Content}} {{str2html .Content}}
</div> </div>
<div class="attachments">
{{range .Attachments}}
<a class="attachment" href="{{.IssueId}}/attachment/{{.Id}}">{{.Name}}</a>
{{end}}
</div>
</div> </div>
</div> </div>
{{else if eq .Type 1}} {{else if eq .Type 1}}
@ -103,8 +108,14 @@
<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>
<div>
<div id="attached"></div>
</div>
<div class="text-right"> <div class="text-right">
<div class="form-group"> <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}} {{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}}&nbsp;&nbsp; <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;