Merge pull request #307 from nuss-justin/feature/attachments
Feature: Ability to attach files to issues (attachments)
This commit is contained in:
commit
da0240aacd
13 changed files with 507 additions and 26 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
12
conf/app.ini
12
conf/app.ini
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -147,7 +147,7 @@ func updateIssuesCommit(userId, repoId int64, repoUserName, repoName string, com
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
185
models/issue.go
185
models/issue.go
|
@ -8,6 +8,7 @@ import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
"html/template"
|
"html/template"
|
||||||
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
@ -15,6 +16,7 @@ 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 (
|
||||||
|
@ -22,6 +24,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")
|
||||||
ErrMissingIssueNumber = errors.New("No issue number specified")
|
ErrMissingIssueNumber = errors.New("No issue number specified")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -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,
|
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.
|
||||||
|
@ -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() {
|
||||||
|
|
|
@ -323,7 +323,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,
|
||||||
|
@ -332,7 +331,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
|
||||||
|
@ -374,6 +372,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 {
|
||||||
|
|
|
@ -1795,3 +1795,45 @@ body {
|
||||||
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>
|
||||||
|
|
|
@ -47,6 +47,17 @@
|
||||||
</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>
|
||||||
|
{{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}}
|
||||||
|
|
Loading…
Reference in a new issue