diff options
Diffstat (limited to 'internal/db')
59 files changed, 18647 insertions, 0 deletions
diff --git a/internal/db/access.go b/internal/db/access.go new file mode 100644 index 00000000..e9c8b5b7 --- /dev/null +++ b/internal/db/access.go @@ -0,0 +1,241 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package db + +import ( + "fmt" + + log "gopkg.in/clog.v1" + + "gogs.io/gogs/internal/db/errors" +) + +type AccessMode int + +const ( + ACCESS_MODE_NONE AccessMode = iota // 0 + ACCESS_MODE_READ // 1 + ACCESS_MODE_WRITE // 2 + ACCESS_MODE_ADMIN // 3 + ACCESS_MODE_OWNER // 4 +) + +func (mode AccessMode) String() string { + switch mode { + case ACCESS_MODE_READ: + return "read" + case ACCESS_MODE_WRITE: + return "write" + case ACCESS_MODE_ADMIN: + return "admin" + case ACCESS_MODE_OWNER: + return "owner" + default: + return "none" + } +} + +// ParseAccessMode returns corresponding access mode to given permission string. +func ParseAccessMode(permission string) AccessMode { + switch permission { + case "write": + return ACCESS_MODE_WRITE + case "admin": + return ACCESS_MODE_ADMIN + default: + return ACCESS_MODE_READ + } +} + +// Access represents the highest access level of a user to the repository. The only access type +// that is not in this table is the real owner of a repository. In case of an organization +// repository, the members of the owners team are in this table. +type Access struct { + ID int64 + UserID int64 `xorm:"UNIQUE(s)"` + RepoID int64 `xorm:"UNIQUE(s)"` + Mode AccessMode +} + +func userAccessMode(e Engine, userID int64, repo *Repository) (AccessMode, error) { + mode := ACCESS_MODE_NONE + // Everyone has read access to public repository + if !repo.IsPrivate { + mode = ACCESS_MODE_READ + } + + if userID <= 0 { + return mode, nil + } + + if userID == repo.OwnerID { + return ACCESS_MODE_OWNER, nil + } + + access := &Access{ + UserID: userID, + RepoID: repo.ID, + } + if has, err := e.Get(access); !has || err != nil { + return mode, err + } + return access.Mode, nil +} + +// UserAccessMode returns the access mode of given user to the repository. +func UserAccessMode(userID int64, repo *Repository) (AccessMode, error) { + return userAccessMode(x, userID, repo) +} + +func hasAccess(e Engine, userID int64, repo *Repository, testMode AccessMode) (bool, error) { + mode, err := userAccessMode(e, userID, repo) + return mode >= testMode, err +} + +// HasAccess returns true if someone has the request access level. User can be nil! +func HasAccess(userID int64, repo *Repository, testMode AccessMode) (bool, error) { + return hasAccess(x, userID, repo, testMode) +} + +// GetRepositoryAccesses finds all repositories with their access mode where a user has access but does not own. +func (u *User) GetRepositoryAccesses() (map[*Repository]AccessMode, error) { + accesses := make([]*Access, 0, 10) + if err := x.Find(&accesses, &Access{UserID: u.ID}); err != nil { + return nil, err + } + + repos := make(map[*Repository]AccessMode, len(accesses)) + for _, access := range accesses { + repo, err := GetRepositoryByID(access.RepoID) + if err != nil { + if errors.IsRepoNotExist(err) { + log.Error(2, "GetRepositoryByID: %v", err) + continue + } + return nil, err + } + if repo.OwnerID == u.ID { + continue + } + repos[repo] = access.Mode + } + return repos, nil +} + +// GetAccessibleRepositories finds repositories which the user has access but does not own. +// If limit is smaller than 1 means returns all found results. +func (user *User) GetAccessibleRepositories(limit int) (repos []*Repository, _ error) { + sess := x.Where("owner_id !=? ", user.ID).Desc("updated_unix") + if limit > 0 { + sess.Limit(limit) + repos = make([]*Repository, 0, limit) + } else { + repos = make([]*Repository, 0, 10) + } + return repos, sess.Join("INNER", "access", "access.user_id = ? AND access.repo_id = repository.id", user.ID).Find(&repos) +} + +func maxAccessMode(modes ...AccessMode) AccessMode { + max := ACCESS_MODE_NONE + for _, mode := range modes { + if mode > max { + max = mode + } + } + return max +} + +// FIXME: do corss-comparison so reduce deletions and additions to the minimum? +func (repo *Repository) refreshAccesses(e Engine, accessMap map[int64]AccessMode) (err error) { + newAccesses := make([]Access, 0, len(accessMap)) + for userID, mode := range accessMap { + newAccesses = append(newAccesses, Access{ + UserID: userID, + RepoID: repo.ID, + Mode: mode, + }) + } + + // Delete old accesses and insert new ones for repository. + if _, err = e.Delete(&Access{RepoID: repo.ID}); err != nil { + return fmt.Errorf("delete old accesses: %v", err) + } else if _, err = e.Insert(newAccesses); err != nil { + return fmt.Errorf("insert new accesses: %v", err) + } + return nil +} + +// refreshCollaboratorAccesses retrieves repository collaborations with their access modes. +func (repo *Repository) refreshCollaboratorAccesses(e Engine, accessMap map[int64]AccessMode) error { + collaborations, err := repo.getCollaborations(e) + if err != nil { + return fmt.Errorf("getCollaborations: %v", err) + } + for _, c := range collaborations { + accessMap[c.UserID] = c.Mode + } + return nil +} + +// recalculateTeamAccesses recalculates new accesses for teams of an organization +// except the team whose ID is given. It is used to assign a team ID when +// remove repository from that team. +func (repo *Repository) recalculateTeamAccesses(e Engine, ignTeamID int64) (err error) { + accessMap := make(map[int64]AccessMode, 20) + + if err = repo.getOwner(e); err != nil { + return err + } else if !repo.Owner.IsOrganization() { + return fmt.Errorf("owner is not an organization: %d", repo.OwnerID) + } + + if err = repo.refreshCollaboratorAccesses(e, accessMap); err != nil { + return fmt.Errorf("refreshCollaboratorAccesses: %v", err) + } + + if err = repo.Owner.getTeams(e); err != nil { + return err + } + + for _, t := range repo.Owner.Teams { + if t.ID == ignTeamID { + continue + } + + // Owner team gets owner access, and skip for teams that do not + // have relations with repository. + if t.IsOwnerTeam() { + t.Authorize = ACCESS_MODE_OWNER + } else if !t.hasRepository(e, repo.ID) { + continue + } + + if err = t.getMembers(e); err != nil { + return fmt.Errorf("getMembers '%d': %v", t.ID, err) + } + for _, m := range t.Members { + accessMap[m.ID] = maxAccessMode(accessMap[m.ID], t.Authorize) + } + } + + return repo.refreshAccesses(e, accessMap) +} + +func (repo *Repository) recalculateAccesses(e Engine) error { + if repo.Owner.IsOrganization() { + return repo.recalculateTeamAccesses(e, 0) + } + + accessMap := make(map[int64]AccessMode, 10) + if err := repo.refreshCollaboratorAccesses(e, accessMap); err != nil { + return fmt.Errorf("refreshCollaboratorAccesses: %v", err) + } + return repo.refreshAccesses(e, accessMap) +} + +// RecalculateAccesses recalculates all accesses for repository. +func (repo *Repository) RecalculateAccesses() error { + return repo.recalculateAccesses(x) +} diff --git a/internal/db/action.go b/internal/db/action.go new file mode 100644 index 00000000..d6006410 --- /dev/null +++ b/internal/db/action.go @@ -0,0 +1,767 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package db + +import ( + "fmt" + "path" + "regexp" + "strings" + "time" + "unicode" + + "github.com/json-iterator/go" + "github.com/unknwon/com" + log "gopkg.in/clog.v1" + "xorm.io/xorm" + + "github.com/gogs/git-module" + api "github.com/gogs/go-gogs-client" + + "gogs.io/gogs/internal/db/errors" + "gogs.io/gogs/internal/setting" + "gogs.io/gogs/internal/tool" +) + +type ActionType int + +// Note: To maintain backward compatibility only append to the end of list +const ( + ACTION_CREATE_REPO ActionType = iota + 1 // 1 + ACTION_RENAME_REPO // 2 + ACTION_STAR_REPO // 3 + ACTION_WATCH_REPO // 4 + ACTION_COMMIT_REPO // 5 + ACTION_CREATE_ISSUE // 6 + ACTION_CREATE_PULL_REQUEST // 7 + ACTION_TRANSFER_REPO // 8 + ACTION_PUSH_TAG // 9 + ACTION_COMMENT_ISSUE // 10 + ACTION_MERGE_PULL_REQUEST // 11 + ACTION_CLOSE_ISSUE // 12 + ACTION_REOPEN_ISSUE // 13 + ACTION_CLOSE_PULL_REQUEST // 14 + ACTION_REOPEN_PULL_REQUEST // 15 + ACTION_CREATE_BRANCH // 16 + ACTION_DELETE_BRANCH // 17 + ACTION_DELETE_TAG // 18 + ACTION_FORK_REPO // 19 + ACTION_MIRROR_SYNC_PUSH // 20 + ACTION_MIRROR_SYNC_CREATE // 21 + ACTION_MIRROR_SYNC_DELETE // 22 +) + +var ( + // Same as Github. See https://help.github.com/articles/closing-issues-via-commit-messages + IssueCloseKeywords = []string{"close", "closes", "closed", "fix", "fixes", "fixed", "resolve", "resolves", "resolved"} + IssueReopenKeywords = []string{"reopen", "reopens", "reopened"} + + IssueCloseKeywordsPat = regexp.MustCompile(assembleKeywordsPattern(IssueCloseKeywords)) + IssueReopenKeywordsPat = regexp.MustCompile(assembleKeywordsPattern(IssueReopenKeywords)) + IssueReferenceKeywordsPat = regexp.MustCompile(`(?i)(?:)(^| )\S+`) +) + +func assembleKeywordsPattern(words []string) string { + return fmt.Sprintf(`(?i)(?:%s) \S+`, strings.Join(words, "|")) +} + +// Action represents user operation type and other information to repository, +// it implemented interface base.Actioner so that can be used in template render. +type Action struct { + ID int64 + UserID int64 // Receiver user ID + OpType ActionType + ActUserID int64 // Doer user ID + ActUserName string // Doer user name + ActAvatar string `xorm:"-" json:"-"` + RepoID int64 `xorm:"INDEX"` + RepoUserName string + RepoName string + RefName string + IsPrivate bool `xorm:"NOT NULL DEFAULT false"` + Content string `xorm:"TEXT"` + Created time.Time `xorm:"-" json:"-"` + CreatedUnix int64 +} + +func (a *Action) BeforeInsert() { + a.CreatedUnix = time.Now().Unix() +} + +func (a *Action) AfterSet(colName string, _ xorm.Cell) { + switch colName { + case "created_unix": + a.Created = time.Unix(a.CreatedUnix, 0).Local() + } +} + +func (a *Action) GetOpType() int { + return int(a.OpType) +} + +func (a *Action) GetActUserName() string { + return a.ActUserName +} + +func (a *Action) ShortActUserName() string { + return tool.EllipsisString(a.ActUserName, 20) +} + +func (a *Action) GetRepoUserName() string { + return a.RepoUserName +} + +func (a *Action) ShortRepoUserName() string { + return tool.EllipsisString(a.RepoUserName, 20) +} + +func (a *Action) GetRepoName() string { + return a.RepoName +} + +func (a *Action) ShortRepoName() string { + return tool.EllipsisString(a.RepoName, 33) +} + +func (a *Action) GetRepoPath() string { + return path.Join(a.RepoUserName, a.RepoName) +} + +func (a *Action) ShortRepoPath() string { + return path.Join(a.ShortRepoUserName(), a.ShortRepoName()) +} + +func (a *Action) GetRepoLink() string { + if len(setting.AppSubURL) > 0 { + return path.Join(setting.AppSubURL, a.GetRepoPath()) + } + return "/" + a.GetRepoPath() +} + +func (a *Action) GetBranch() string { + return a.RefName +} + +func (a *Action) GetContent() string { + return a.Content +} + +func (a *Action) GetCreate() time.Time { + return a.Created +} + +func (a *Action) GetIssueInfos() []string { + return strings.SplitN(a.Content, "|", 2) +} + +func (a *Action) GetIssueTitle() string { + index := com.StrTo(a.GetIssueInfos()[0]).MustInt64() + issue, err := GetIssueByIndex(a.RepoID, index) + if err != nil { + log.Error(4, "GetIssueByIndex: %v", err) + return "500 when get issue" + } + return issue.Title +} + +func (a *Action) GetIssueContent() string { + index := com.StrTo(a.GetIssueInfos()[0]).MustInt64() + issue, err := GetIssueByIndex(a.RepoID, index) + if err != nil { + log.Error(4, "GetIssueByIndex: %v", err) + return "500 when get issue" + } + return issue.Content +} + +func newRepoAction(e Engine, doer, owner *User, repo *Repository) (err error) { + opType := ACTION_CREATE_REPO + if repo.IsFork { + opType = ACTION_FORK_REPO + } + + return notifyWatchers(e, &Action{ + ActUserID: doer.ID, + ActUserName: doer.Name, + OpType: opType, + RepoID: repo.ID, + RepoUserName: repo.Owner.Name, + RepoName: repo.Name, + IsPrivate: repo.IsPrivate, + }) +} + +// NewRepoAction adds new action for creating repository. +func NewRepoAction(doer, owner *User, repo *Repository) (err error) { + return newRepoAction(x, doer, owner, repo) +} + +func renameRepoAction(e Engine, actUser *User, oldRepoName string, repo *Repository) (err error) { + if err = notifyWatchers(e, &Action{ + ActUserID: actUser.ID, + ActUserName: actUser.Name, + OpType: ACTION_RENAME_REPO, + RepoID: repo.ID, + RepoUserName: repo.Owner.Name, + RepoName: repo.Name, + IsPrivate: repo.IsPrivate, + Content: oldRepoName, + }); err != nil { + return fmt.Errorf("notify watchers: %v", err) + } + + log.Trace("action.renameRepoAction: %s/%s", actUser.Name, repo.Name) + return nil +} + +// RenameRepoAction adds new action for renaming a repository. +func RenameRepoAction(actUser *User, oldRepoName string, repo *Repository) error { + return renameRepoAction(x, actUser, oldRepoName, repo) +} + +func issueIndexTrimRight(c rune) bool { + return !unicode.IsDigit(c) +} + +type PushCommit struct { + Sha1 string + Message string + AuthorEmail string + AuthorName string + CommitterEmail string + CommitterName string + Timestamp time.Time +} + +type PushCommits struct { + Len int + Commits []*PushCommit + CompareURL string + + avatars map[string]string +} + +func NewPushCommits() *PushCommits { + return &PushCommits{ + avatars: make(map[string]string), + } +} + +func (pc *PushCommits) ToApiPayloadCommits(repoPath, repoURL string) ([]*api.PayloadCommit, error) { + commits := make([]*api.PayloadCommit, len(pc.Commits)) + for i, commit := range pc.Commits { + authorUsername := "" + author, err := GetUserByEmail(commit.AuthorEmail) + if err == nil { + authorUsername = author.Name + } else if !errors.IsUserNotExist(err) { + return nil, fmt.Errorf("GetUserByEmail: %v", err) + } + + committerUsername := "" + committer, err := GetUserByEmail(commit.CommitterEmail) + if err == nil { + committerUsername = committer.Name + } else if !errors.IsUserNotExist(err) { + return nil, fmt.Errorf("GetUserByEmail: %v", err) + } + + fileStatus, err := git.GetCommitFileStatus(repoPath, commit.Sha1) + if err != nil { + return nil, fmt.Errorf("FileStatus [commit_sha1: %s]: %v", commit.Sha1, err) + } + + commits[i] = &api.PayloadCommit{ + ID: commit.Sha1, + Message: commit.Message, + URL: fmt.Sprintf("%s/commit/%s", repoURL, commit.Sha1), + Author: &api.PayloadUser{ + Name: commit.AuthorName, + Email: commit.AuthorEmail, + UserName: authorUsername, + }, + Committer: &api.PayloadUser{ + Name: commit.CommitterName, + Email: commit.CommitterEmail, + UserName: committerUsername, + }, + Added: fileStatus.Added, + Removed: fileStatus.Removed, + Modified: fileStatus.Modified, + Timestamp: commit.Timestamp, + } + } + return commits, nil +} + +// AvatarLink tries to match user in database with e-mail +// in order to show custom avatar, and falls back to general avatar link. +func (push *PushCommits) AvatarLink(email string) string { + _, ok := push.avatars[email] + if !ok { + u, err := GetUserByEmail(email) + if err != nil { + push.avatars[email] = tool.AvatarLink(email) + if !errors.IsUserNotExist(err) { + log.Error(4, "GetUserByEmail: %v", err) + } + } else { + push.avatars[email] = u.RelAvatarLink() + } + } + + return push.avatars[email] +} + +// UpdateIssuesCommit checks if issues are manipulated by commit message. +func UpdateIssuesCommit(doer *User, repo *Repository, commits []*PushCommit) error { + // Commits are appended in the reverse order. + for i := len(commits) - 1; i >= 0; i-- { + c := commits[i] + + refMarked := make(map[int64]bool) + for _, ref := range IssueReferenceKeywordsPat.FindAllString(c.Message, -1) { + ref = ref[strings.IndexByte(ref, byte(' '))+1:] + ref = strings.TrimRightFunc(ref, issueIndexTrimRight) + + if len(ref) == 0 { + continue + } + + // Add repo name if missing + if ref[0] == '#' { + ref = fmt.Sprintf("%s%s", repo.FullName(), ref) + } else if !strings.Contains(ref, "/") { + // FIXME: We don't support User#ID syntax yet + // return ErrNotImplemented + continue + } + + issue, err := GetIssueByRef(ref) + if err != nil { + if errors.IsIssueNotExist(err) { + continue + } + return err + } + + if refMarked[issue.ID] { + continue + } + refMarked[issue.ID] = true + + msgLines := strings.Split(c.Message, "\n") + shortMsg := msgLines[0] + if len(msgLines) > 2 { + shortMsg += "..." + } + message := fmt.Sprintf(`<a href="%s/commit/%s">%s</a>`, repo.Link(), c.Sha1, shortMsg) + if err = CreateRefComment(doer, repo, issue, message, c.Sha1); err != nil { + return err + } + } + + refMarked = make(map[int64]bool) + // FIXME: can merge this one and next one to a common function. + for _, ref := range IssueCloseKeywordsPat.FindAllString(c.Message, -1) { + ref = ref[strings.IndexByte(ref, byte(' '))+1:] + ref = strings.TrimRightFunc(ref, issueIndexTrimRight) + + if len(ref) == 0 { + continue + } + + // Add repo name if missing + if ref[0] == '#' { + ref = fmt.Sprintf("%s%s", repo.FullName(), ref) + } else if !strings.Contains(ref, "/") { + // FIXME: We don't support User#ID syntax yet + continue + } + + issue, err := GetIssueByRef(ref) + if err != nil { + if errors.IsIssueNotExist(err) { + continue + } + return err + } + + if refMarked[issue.ID] { + continue + } + refMarked[issue.ID] = true + + if issue.RepoID != repo.ID || issue.IsClosed { + continue + } + + if err = issue.ChangeStatus(doer, repo, true); err != nil { + return err + } + } + + // It is conflict to have close and reopen at same time, so refsMarkd doesn't need to reinit here. + for _, ref := range IssueReopenKeywordsPat.FindAllString(c.Message, -1) { + ref = ref[strings.IndexByte(ref, byte(' '))+1:] + ref = strings.TrimRightFunc(ref, issueIndexTrimRight) + + if len(ref) == 0 { + continue + } + + // Add repo name if missing + if ref[0] == '#' { + ref = fmt.Sprintf("%s%s", repo.FullName(), ref) + } else if !strings.Contains(ref, "/") { + // We don't support User#ID syntax yet + // return ErrNotImplemented + continue + } + + issue, err := GetIssueByRef(ref) + if err != nil { + if errors.IsIssueNotExist(err) { + continue + } + return err + } + + if refMarked[issue.ID] { + continue + } + refMarked[issue.ID] = true + + if issue.RepoID != repo.ID || !issue.IsClosed { + continue + } + + if err = issue.ChangeStatus(doer, repo, false); err != nil { + return err + } + } + } + return nil +} + +type CommitRepoActionOptions struct { + PusherName string + RepoOwnerID int64 + RepoName string + RefFullName string + OldCommitID string + NewCommitID string + Commits *PushCommits +} + +// CommitRepoAction adds new commit actio to the repository, and prepare corresponding webhooks. +func CommitRepoAction(opts CommitRepoActionOptions) error { + pusher, err := GetUserByName(opts.PusherName) + if err != nil { + return fmt.Errorf("GetUserByName [%s]: %v", opts.PusherName, err) + } + + repo, err := GetRepositoryByName(opts.RepoOwnerID, opts.RepoName) + if err != nil { + return fmt.Errorf("GetRepositoryByName [owner_id: %d, name: %s]: %v", opts.RepoOwnerID, opts.RepoName, err) + } + + // Change repository bare status and update last updated time. + repo.IsBare = false + if err = UpdateRepository(repo, false); err != nil { + return fmt.Errorf("UpdateRepository: %v", err) + } + + isNewRef := opts.OldCommitID == git.EMPTY_SHA + isDelRef := opts.NewCommitID == git.EMPTY_SHA + + opType := ACTION_COMMIT_REPO + // Check if it's tag push or branch. + if strings.HasPrefix(opts.RefFullName, git.TAG_PREFIX) { + opType = ACTION_PUSH_TAG + } else { + // if not the first commit, set the compare URL. + if !isNewRef && !isDelRef { + opts.Commits.CompareURL = repo.ComposeCompareURL(opts.OldCommitID, opts.NewCommitID) + } + + // Only update issues via commits when internal issue tracker is enabled + if repo.EnableIssues && !repo.EnableExternalTracker { + if err = UpdateIssuesCommit(pusher, repo, opts.Commits.Commits); err != nil { + log.Error(2, "UpdateIssuesCommit: %v", err) + } + } + } + + if len(opts.Commits.Commits) > setting.UI.FeedMaxCommitNum { + opts.Commits.Commits = opts.Commits.Commits[:setting.UI.FeedMaxCommitNum] + } + + data, err := jsoniter.Marshal(opts.Commits) + if err != nil { + return fmt.Errorf("Marshal: %v", err) + } + + refName := git.RefEndName(opts.RefFullName) + action := &Action{ + ActUserID: pusher.ID, + ActUserName: pusher.Name, + Content: string(data), + RepoID: repo.ID, + RepoUserName: repo.MustOwner().Name, + RepoName: repo.Name, + RefName: refName, + IsPrivate: repo.IsPrivate, + } + + apiRepo := repo.APIFormat(nil) + apiPusher := pusher.APIFormat() + switch opType { + case ACTION_COMMIT_REPO: // Push + if isDelRef { + if err = PrepareWebhooks(repo, HOOK_EVENT_DELETE, &api.DeletePayload{ + Ref: refName, + RefType: "branch", + PusherType: api.PUSHER_TYPE_USER, + Repo: apiRepo, + Sender: apiPusher, + }); err != nil { + return fmt.Errorf("PrepareWebhooks.(delete branch): %v", err) + } + + action.OpType = ACTION_DELETE_BRANCH + if err = NotifyWatchers(action); err != nil { + return fmt.Errorf("NotifyWatchers.(delete branch): %v", err) + } + + // Delete branch doesn't have anything to push or compare + return nil + } + + compareURL := setting.AppURL + opts.Commits.CompareURL + if isNewRef { + compareURL = "" + if err = PrepareWebhooks(repo, HOOK_EVENT_CREATE, &api.CreatePayload{ + Ref: refName, + RefType: "branch", + DefaultBranch: repo.DefaultBranch, + Repo: apiRepo, + Sender: apiPusher, + }); err != nil { + return fmt.Errorf("PrepareWebhooks.(new branch): %v", err) + } + + action.OpType = ACTION_CREATE_BRANCH + if err = NotifyWatchers(action); err != nil { + return fmt.Errorf("NotifyWatchers.(new branch): %v", err) + } + } + + commits, err := opts.Commits.ToApiPayloadCommits(repo.RepoPath(), repo.HTMLURL()) + if err != nil { + return fmt.Errorf("ToApiPayloadCommits: %v", err) + } + + if err = PrepareWebhooks(repo, HOOK_EVENT_PUSH, &api.PushPayload{ + Ref: opts.RefFullName, + Before: opts.OldCommitID, + After: opts.NewCommitID, + CompareURL: compareURL, + Commits: commits, + Repo: apiRepo, + Pusher: apiPusher, + Sender: apiPusher, + }); err != nil { + return fmt.Errorf("PrepareWebhooks.(new commit): %v", err) + } + + action.OpType = ACTION_COMMIT_REPO + if err = NotifyWatchers(action); err != nil { + return fmt.Errorf("NotifyWatchers.(new commit): %v", err) + } + + case ACTION_PUSH_TAG: // Tag + if isDelRef { + if err = PrepareWebhooks(repo, HOOK_EVENT_DELETE, &api.DeletePayload{ + Ref: refName, + RefType: "tag", + PusherType: api.PUSHER_TYPE_USER, + Repo: apiRepo, + Sender: apiPusher, + }); err != nil { + return fmt.Errorf("PrepareWebhooks.(delete tag): %v", err) + } + + action.OpType = ACTION_DELETE_TAG + if err = NotifyWatchers(action); err != nil { + return fmt.Errorf("NotifyWatchers.(delete tag): %v", err) + } + return nil + } + + if err = PrepareWebhooks(repo, HOOK_EVENT_CREATE, &api.CreatePayload{ + Ref: refName, + RefType: "tag", + Sha: opts.NewCommitID, + DefaultBranch: repo.DefaultBranch, + Repo: apiRepo, + Sender: apiPusher, + }); err != nil { + return fmt.Errorf("PrepareWebhooks.(new tag): %v", err) + } + + action.OpType = ACTION_PUSH_TAG + if err = NotifyWatchers(action); err != nil { + return fmt.Errorf("NotifyWatchers.(new tag): %v", err) + } + } + + return nil +} + +func transferRepoAction(e Engine, doer, oldOwner *User, repo *Repository) (err error) { + if err = notifyWatchers(e, &Action{ + ActUserID: doer.ID, + ActUserName: doer.Name, + OpType: ACTION_TRANSFER_REPO, + RepoID: repo.ID, + RepoUserName: repo.Owner.Name, + RepoName: repo.Name, + IsPrivate: repo.IsPrivate, + Content: path.Join(oldOwner.Name, repo.Name), + }); err != nil { + return fmt.Errorf("notifyWatchers: %v", err) + } + + // Remove watch for organization. + if oldOwner.IsOrganization() { + if err = watchRepo(e, oldOwner.ID, repo.ID, false); err != nil { + return fmt.Errorf("watchRepo [false]: %v", err) + } + } + + return nil +} + +// TransferRepoAction adds new action for transferring repository, +// the Owner field of repository is assumed to be new owner. +func TransferRepoAction(doer, oldOwner *User, repo *Repository) error { + return transferRepoAction(x, doer, oldOwner, repo) +} + +func mergePullRequestAction(e Engine, doer *User, repo *Repository, issue *Issue) error { + return notifyWatchers(e, &Action{ + ActUserID: doer.ID, + ActUserName: doer.Name, + OpType: ACTION_MERGE_PULL_REQUEST, + Content: fmt.Sprintf("%d|%s", issue.Index, issue.Title), + RepoID: repo.ID, + RepoUserName: repo.Owner.Name, + RepoName: repo.Name, + IsPrivate: repo.IsPrivate, + }) +} + +// MergePullRequestAction adds new action for merging pull request. +func MergePullRequestAction(actUser *User, repo *Repository, pull *Issue) error { + return mergePullRequestAction(x, actUser, repo, pull) +} + +func mirrorSyncAction(opType ActionType, repo *Repository, refName string, data []byte) error { + return NotifyWatchers(&Action{ + ActUserID: repo.OwnerID, + ActUserName: repo.MustOwner().Name, + OpType: opType, + Content: string(data), + RepoID: repo.ID, + RepoUserName: repo.MustOwner().Name, + RepoName: repo.Name, + RefName: refName, + IsPrivate: repo.IsPrivate, + }) +} + +type MirrorSyncPushActionOptions struct { + RefName string + OldCommitID string + NewCommitID string + Commits *PushCommits +} + +// MirrorSyncPushAction adds new action for mirror synchronization of pushed commits. +func MirrorSyncPushAction(repo *Repository, opts MirrorSyncPushActionOptions) error { + if len(opts.Commits.Commits) > setting.UI.FeedMaxCommitNum { + opts.Commits.Commits = opts.Commits.Commits[:setting.UI.FeedMaxCommitNum] + } + + apiCommits, err := opts.Commits.ToApiPayloadCommits(repo.RepoPath(), repo.HTMLURL()) + if err != nil { + return fmt.Errorf("ToApiPayloadCommits: %v", err) + } + + opts.Commits.CompareURL = repo.ComposeCompareURL(opts.OldCommitID, opts.NewCommitID) + apiPusher := repo.MustOwner().APIFormat() + if err := PrepareWebhooks(repo, HOOK_EVENT_PUSH, &api.PushPayload{ + Ref: opts.RefName, + Before: opts.OldCommitID, + After: opts.NewCommitID, + CompareURL: setting.AppURL + opts.Commits.CompareURL, + Commits: apiCommits, + Repo: repo.APIFormat(nil), + Pusher: apiPusher, + Sender: apiPusher, + }); err != nil { + return fmt.Errorf("PrepareWebhooks: %v", err) + } + + data, err := jsoniter.Marshal(opts.Commits) + if err != nil { + return err + } + + return mirrorSyncAction(ACTION_MIRROR_SYNC_PUSH, repo, opts.RefName, data) +} + +// MirrorSyncCreateAction adds new action for mirror synchronization of new reference. +func MirrorSyncCreateAction(repo *Repository, refName string) error { + return mirrorSyncAction(ACTION_MIRROR_SYNC_CREATE, repo, refName, nil) +} + +// MirrorSyncCreateAction adds new action for mirror synchronization of delete reference. +func MirrorSyncDeleteAction(repo *Repository, refName string) error { + return mirrorSyncAction(ACTION_MIRROR_SYNC_DELETE, repo, refName, nil) +} + +// GetFeeds returns action list of given user in given context. +// actorID is the user who's requesting, ctxUserID is the user/org that is requested. +// actorID can be -1 when isProfile is true or to skip the permission check. +func GetFeeds(ctxUser *User, actorID, afterID int64, isProfile bool) ([]*Action, error) { + actions := make([]*Action, 0, setting.UI.User.NewsFeedPagingNum) + sess := x.Limit(setting.UI.User.NewsFeedPagingNum).Where("user_id = ?", ctxUser.ID).Desc("id") + if afterID > 0 { + sess.And("id < ?", afterID) + } + if isProfile { + sess.And("is_private = ?", false).And("act_user_id = ?", ctxUser.ID) + } else if actorID != -1 && ctxUser.IsOrganization() { + // FIXME: only need to get IDs here, not all fields of repository. + repos, _, err := ctxUser.GetUserRepositories(actorID, 1, ctxUser.NumRepos) + if err != nil { + return nil, fmt.Errorf("GetUserRepositories: %v", err) + } + + var repoIDs []int64 + for _, repo := range repos { + repoIDs = append(repoIDs, repo.ID) + } + + if len(repoIDs) > 0 { + sess.In("repo_id", repoIDs) + } + } + + err := sess.Find(&actions) + return actions, err +} diff --git a/internal/db/admin.go b/internal/db/admin.go new file mode 100644 index 00000000..1fe13002 --- /dev/null +++ b/internal/db/admin.go @@ -0,0 +1,118 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package db + +import ( + "fmt" + "os" + "strings" + "time" + + "github.com/unknwon/com" + log "gopkg.in/clog.v1" + "xorm.io/xorm" + + "gogs.io/gogs/internal/tool" +) + +type NoticeType int + +const ( + NOTICE_REPOSITORY NoticeType = iota + 1 +) + +// Notice represents a system notice for admin. +type Notice struct { + ID int64 + Type NoticeType + Description string `xorm:"TEXT"` + Created time.Time `xorm:"-" json:"-"` + CreatedUnix int64 +} + +func (n *Notice) BeforeInsert() { + n.CreatedUnix = time.Now().Unix() +} + +func (n *Notice) AfterSet(colName string, _ xorm.Cell) { + switch colName { + case "created_unix": + n.Created = time.Unix(n.CreatedUnix, 0).Local() + } +} + +// TrStr returns a translation format string. +func (n *Notice) TrStr() string { + return "admin.notices.type_" + com.ToStr(n.Type) +} + +// CreateNotice creates new system notice. +func CreateNotice(tp NoticeType, desc string) error { + // Prevent panic if database connection is not available at this point + if x == nil { + return fmt.Errorf("could not save notice due database connection not being available: %d %s", tp, desc) + } + + n := &Notice{ + Type: tp, + Description: desc, + } + _, err := x.Insert(n) + return err +} + +// CreateRepositoryNotice creates new system notice with type NOTICE_REPOSITORY. +func CreateRepositoryNotice(desc string) error { + return CreateNotice(NOTICE_REPOSITORY, desc) +} + +// RemoveAllWithNotice removes all directories in given path and +// creates a system notice when error occurs. +func RemoveAllWithNotice(title, path string) { + if err := os.RemoveAll(path); err != nil { + desc := fmt.Sprintf("%s [%s]: %v", title, path, err) + log.Warn(desc) + if err = CreateRepositoryNotice(desc); err != nil { + log.Error(2, "CreateRepositoryNotice: %v", err) + } + } +} + +// CountNotices returns number of notices. +func CountNotices() int64 { + count, _ := x.Count(new(Notice)) + return count +} + +// Notices returns number of notices in given page. +func Notices(page, pageSize int) ([]*Notice, error) { + notices := make([]*Notice, 0, pageSize) + return notices, x.Limit(pageSize, (page-1)*pageSize).Desc("id").Find(¬ices) +} + +// DeleteNotice deletes a system notice by given ID. +func DeleteNotice(id int64) error { + _, err := x.Id(id).Delete(new(Notice)) + return err +} + +// DeleteNotices deletes all notices with ID from start to end (inclusive). +func DeleteNotices(start, end int64) error { + sess := x.Where("id >= ?", start) + if end > 0 { + sess.And("id <= ?", end) + } + _, err := sess.Delete(new(Notice)) + return err +} + +// DeleteNoticesByIDs deletes notices by given IDs. +func DeleteNoticesByIDs(ids []int64) error { + if len(ids) == 0 { + return nil + } + _, err := x.Where("id IN (" + strings.Join(tool.Int64sToStrings(ids), ",") + ")").Delete(new(Notice)) + return err +} diff --git a/internal/db/attachment.go b/internal/db/attachment.go new file mode 100644 index 00000000..1494002d --- /dev/null +++ b/internal/db/attachment.go @@ -0,0 +1,183 @@ +// Copyright 2017 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package db + +import ( + "fmt" + "io" + "mime/multipart" + "os" + "path" + "time" + + gouuid "github.com/satori/go.uuid" + "xorm.io/xorm" + + "gogs.io/gogs/internal/setting" +) + +// Attachment represent a attachment of issue/comment/release. +type Attachment struct { + ID int64 + UUID string `xorm:"uuid UNIQUE"` + IssueID int64 `xorm:"INDEX"` + CommentID int64 + ReleaseID int64 `xorm:"INDEX"` + Name string + + Created time.Time `xorm:"-" json:"-"` + CreatedUnix int64 +} + +func (a *Attachment) BeforeInsert() { + a.CreatedUnix = time.Now().Unix() +} + +func (a *Attachment) AfterSet(colName string, _ xorm.Cell) { + switch colName { + case "created_unix": + a.Created = time.Unix(a.CreatedUnix, 0).Local() + } +} + +// AttachmentLocalPath returns where attachment is stored in local file system based on given UUID. +func AttachmentLocalPath(uuid string) string { + return path.Join(setting.AttachmentPath, uuid[0:1], uuid[1:2], uuid) +} + +// LocalPath returns where attachment is stored in local file system. +func (attach *Attachment) LocalPath() string { + return AttachmentLocalPath(attach.UUID) +} + +// NewAttachment creates a new attachment object. +func NewAttachment(name string, buf []byte, file multipart.File) (_ *Attachment, err error) { + attach := &Attachment{ + UUID: gouuid.NewV4().String(), + Name: name, + } + + localPath := attach.LocalPath() + if err = os.MkdirAll(path.Dir(localPath), os.ModePerm); err != nil { + return nil, fmt.Errorf("MkdirAll: %v", err) + } + + fw, err := os.Create(localPath) + if err != nil { + return nil, fmt.Errorf("Create: %v", err) + } + defer fw.Close() + + if _, err = fw.Write(buf); err != nil { + return nil, fmt.Errorf("Write: %v", err) + } else if _, err = io.Copy(fw, file); err != nil { + return nil, fmt.Errorf("Copy: %v", err) + } + + if _, err := x.Insert(attach); err != nil { + return nil, err + } + + return attach, nil +} + +func getAttachmentByUUID(e Engine, uuid string) (*Attachment, error) { + attach := &Attachment{UUID: uuid} + has, err := x.Get(attach) + if err != nil { + return nil, err + } else if !has { + return nil, ErrAttachmentNotExist{0, uuid} + } + return attach, nil +} + +func getAttachmentsByUUIDs(e Engine, uuids []string) ([]*Attachment, error) { + if len(uuids) == 0 { + return []*Attachment{}, nil + } + + // Silently drop invalid uuids. + attachments := make([]*Attachment, 0, len(uuids)) + return attachments, e.In("uuid", uuids).Find(&attachments) +} + +// GetAttachmentByUUID returns attachment by given UUID. +func GetAttachmentByUUID(uuid string) (*Attachment, error) { + return getAttachmentByUUID(x, uuid) +} + +func getAttachmentsByIssueID(e Engine, issueID int64) ([]*Attachment, error) { + attachments := make([]*Attachment, 0, 5) + return attachments, e.Where("issue_id = ? AND comment_id = 0", issueID).Find(&attachments) +} + +// GetAttachmentsByIssueID returns all attachments of an issue. +func GetAttachmentsByIssueID(issueID int64) ([]*Attachment, error) { + return getAttachmentsByIssueID(x, issueID) +} + +func getAttachmentsByCommentID(e Engine, commentID int64) ([]*Attachment, error) { + attachments := make([]*Attachment, 0, 5) + return attachments, e.Where("comment_id=?", commentID).Find(&attachments) +} + +// GetAttachmentsByCommentID returns all attachments of a comment. +func GetAttachmentsByCommentID(commentID int64) ([]*Attachment, error) { + return getAttachmentsByCommentID(x, commentID) +} + +func getAttachmentsByReleaseID(e Engine, releaseID int64) ([]*Attachment, error) { + attachments := make([]*Attachment, 0, 10) + return attachments, e.Where("release_id = ?", releaseID).Find(&attachments) +} + +// GetAttachmentsByReleaseID returns all attachments of a release. +func GetAttachmentsByReleaseID(releaseID int64) ([]*Attachment, error) { + return getAttachmentsByReleaseID(x, releaseID) +} + +// 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.LocalPath()); err != nil { + return i, err + } + } + + if _, err := x.Delete(a); 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 := GetAttachmentsByIssueID(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 := GetAttachmentsByCommentID(commentId) + if err != nil { + return 0, err + } + + return DeleteAttachments(attachments, remove) +} diff --git a/internal/db/comment.go b/internal/db/comment.go new file mode 100644 index 00000000..b2f19508 --- /dev/null +++ b/internal/db/comment.go @@ -0,0 +1,534 @@ +// Copyright 2016 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package db + +import ( + "fmt" + "strings" + "time" + + "github.com/unknwon/com" + log "gopkg.in/clog.v1" + "xorm.io/xorm" + + api "github.com/gogs/go-gogs-client" + + "gogs.io/gogs/internal/db/errors" + "gogs.io/gogs/internal/markup" +) + +// CommentType defines whether a comment is just a simple comment, an action (like close) or a reference. +type CommentType int + +const ( + // Plain comment, can be associated with a commit (CommitID > 0) and a line (LineNum > 0) + COMMENT_TYPE_COMMENT CommentType = iota + COMMENT_TYPE_REOPEN + COMMENT_TYPE_CLOSE + + // References. + COMMENT_TYPE_ISSUE_REF + // Reference from a commit (not part of a pull request) + COMMENT_TYPE_COMMIT_REF + // Reference from a comment + COMMENT_TYPE_COMMENT_REF + // Reference from a pull request + COMMENT_TYPE_PULL_REF +) + +type CommentTag int + +const ( + COMMENT_TAG_NONE CommentTag = iota + COMMENT_TAG_POSTER + COMMENT_TAG_WRITER + COMMENT_TAG_OWNER +) + +// Comment represents a comment in commit and issue page. +type Comment struct { + ID int64 + Type CommentType + PosterID int64 + Poster *User `xorm:"-" json:"-"` + IssueID int64 `xorm:"INDEX"` + Issue *Issue `xorm:"-" json:"-"` + CommitID int64 + Line int64 + Content string `xorm:"TEXT"` + RenderedContent string `xorm:"-" json:"-"` + + Created time.Time `xorm:"-" json:"-"` + CreatedUnix int64 + Updated time.Time `xorm:"-" json:"-"` + UpdatedUnix int64 + + // Reference issue in commit message + CommitSHA string `xorm:"VARCHAR(40)"` + + Attachments []*Attachment `xorm:"-" json:"-"` + + // For view issue page. + ShowTag CommentTag `xorm:"-" json:"-"` +} + +func (c *Comment) BeforeInsert() { + c.CreatedUnix = time.Now().Unix() + c.UpdatedUnix = c.CreatedUnix +} + +func (c *Comment) BeforeUpdate() { + c.UpdatedUnix = time.Now().Unix() +} + +func (c *Comment) AfterSet(colName string, _ xorm.Cell) { + switch colName { + case "created_unix": + c.Created = time.Unix(c.CreatedUnix, 0).Local() + case "updated_unix": + c.Updated = time.Unix(c.UpdatedUnix, 0).Local() + } +} + +func (c *Comment) loadAttributes(e Engine) (err error) { + if c.Poster == nil { + c.Poster, err = GetUserByID(c.PosterID) + if err != nil { + if errors.IsUserNotExist(err) { + c.PosterID = -1 + c.Poster = NewGhostUser() + } else { + return fmt.Errorf("getUserByID.(Poster) [%d]: %v", c.PosterID, err) + } + } + } + + if c.Issue == nil { + c.Issue, err = getRawIssueByID(e, c.IssueID) + if err != nil { + return fmt.Errorf("getIssueByID [%d]: %v", c.IssueID, err) + } + if c.Issue.Repo == nil { + c.Issue.Repo, err = getRepositoryByID(e, c.Issue.RepoID) + if err != nil { + return fmt.Errorf("getRepositoryByID [%d]: %v", c.Issue.RepoID, err) + } + } + } + + if c.Attachments == nil { + c.Attachments, err = getAttachmentsByCommentID(e, c.ID) + if err != nil { + return fmt.Errorf("getAttachmentsByCommentID [%d]: %v", c.ID, err) + } + } + + return nil +} + +func (c *Comment) LoadAttributes() error { + return c.loadAttributes(x) +} + +func (c *Comment) HTMLURL() string { + return fmt.Sprintf("%s#issuecomment-%d", c.Issue.HTMLURL(), c.ID) +} + +// This method assumes following fields have been assigned with valid values: +// Required - Poster, Issue +func (c *Comment) APIFormat() *api.Comment { + return &api.Comment{ + ID: c.ID, + HTMLURL: c.HTMLURL(), + Poster: c.Poster.APIFormat(), + Body: c.Content, + Created: c.Created, + Updated: c.Updated, + } +} + +func CommentHashTag(id int64) string { + return "issuecomment-" + com.ToStr(id) +} + +// HashTag returns unique hash tag for comment. +func (c *Comment) HashTag() string { + return CommentHashTag(c.ID) +} + +// EventTag returns unique event hash tag for comment. +func (c *Comment) EventTag() string { + return "event-" + com.ToStr(c.ID) +} + +// mailParticipants sends new comment emails to repository watchers +// and mentioned people. +func (cmt *Comment) mailParticipants(e Engine, opType ActionType, issue *Issue) (err error) { + mentions := markup.FindAllMentions(cmt.Content) + if err = updateIssueMentions(e, cmt.IssueID, mentions); err != nil { + return fmt.Errorf("UpdateIssueMentions [%d]: %v", cmt.IssueID, err) + } + + switch opType { + case ACTION_COMMENT_ISSUE: + issue.Content = cmt.Content + case ACTION_CLOSE_ISSUE: + issue.Content = fmt.Sprintf("Closed #%d", issue.Index) + case ACTION_REOPEN_ISSUE: + issue.Content = fmt.Sprintf("Reopened #%d", issue.Index) + } + if err = mailIssueCommentToParticipants(issue, cmt.Poster, mentions); err != nil { + log.Error(2, "mailIssueCommentToParticipants: %v", err) + } + + return nil +} + +func createComment(e *xorm.Session, opts *CreateCommentOptions) (_ *Comment, err error) { + comment := &Comment{ + Type: opts.Type, + PosterID: opts.Doer.ID, + Poster: opts.Doer, + IssueID: opts.Issue.ID, + CommitID: opts.CommitID, + CommitSHA: opts.CommitSHA, + Line: opts.LineNum, + Content: opts.Content, + } + if _, err = e.Insert(comment); err != nil { + return nil, err + } + + // Compose comment action, could be plain comment, close or reopen issue/pull request. + // This object will be used to notify watchers in the end of function. + act := &Action{ + ActUserID: opts.Doer.ID, + ActUserName: opts.Doer.Name, + Content: fmt.Sprintf("%d|%s", opts.Issue.Index, strings.Split(opts.Content, "\n")[0]), + RepoID: opts.Repo.ID, + RepoUserName: opts.Repo.Owner.Name, + RepoName: opts.Repo.Name, + IsPrivate: opts.Repo.IsPrivate, + } + + // Check comment type. + switch opts.Type { + case COMMENT_TYPE_COMMENT: + act.OpType = ACTION_COMMENT_ISSUE + + if _, err = e.Exec("UPDATE `issue` SET num_comments=num_comments+1 WHERE id=?", opts.Issue.ID); err != nil { + return nil, err + } + + // Check attachments + attachments := make([]*Attachment, 0, len(opts.Attachments)) + for _, uuid := range opts.Attachments { + attach, err := getAttachmentByUUID(e, uuid) + if err != nil { + if IsErrAttachmentNotExist(err) { + continue + } + return nil, fmt.Errorf("getAttachmentByUUID [%s]: %v", uuid, err) + } + attachments = append(attachments, attach) + } + + for i := range attachments { + attachments[i].IssueID = opts.Issue.ID + attachments[i].CommentID = comment.ID + // No assign value could be 0, so ignore AllCols(). + if _, err = e.Id(attachments[i].ID).Update(attachments[i]); err != nil { + return nil, fmt.Errorf("update attachment [%d]: %v", attachments[i].ID, err) + } + } + + case COMMENT_TYPE_REOPEN: + act.OpType = ACTION_REOPEN_ISSUE + if opts.Issue.IsPull { + act.OpType = ACTION_REOPEN_PULL_REQUEST + } + + if opts.Issue.IsPull { + _, err = e.Exec("UPDATE `repository` SET num_closed_pulls=num_closed_pulls-1 WHERE id=?", opts.Repo.ID) + } else { + _, err = e.Exec("UPDATE `repository` SET num_closed_issues=num_closed_issues-1 WHERE id=?", opts.Repo.ID) + } + if err != nil { + return nil, err + } + + case COMMENT_TYPE_CLOSE: + act.OpType = ACTION_CLOSE_ISSUE + if opts.Issue.IsPull { + act.OpType = ACTION_CLOSE_PULL_REQUEST + } + + if opts.Issue.IsPull { + _, err = e.Exec("UPDATE `repository` SET num_closed_pulls=num_closed_pulls+1 WHERE id=?", opts.Repo.ID) + } else { + _, err = e.Exec("UPDATE `repository` SET num_closed_issues=num_closed_issues+1 WHERE id=?", opts.Repo.ID) + } + if err != nil { + return nil, err + } + } + + if _, err = e.Exec("UPDATE `issue` SET updated_unix = ? WHERE id = ?", time.Now().Unix(), opts.Issue.ID); err != nil { + return nil, fmt.Errorf("update issue 'updated_unix': %v", err) + } + + // Notify watchers for whatever action comes in, ignore if no action type. + if act.OpType > 0 { + if err = notifyWatchers(e, act); err != nil { + log.Error(2, "notifyWatchers: %v", err) + } + if err = comment.mailParticipants(e, act.OpType, opts.Issue); err != nil { + log.Error(2, "MailParticipants: %v", err) + } + } + + return comment, comment.loadAttributes(e) +} + +func createStatusComment(e *xorm.Session, doer *User, repo *Repository, issue *Issue) (*Comment, error) { + cmtType := COMMENT_TYPE_CLOSE + if !issue.IsClosed { + cmtType = COMMENT_TYPE_REOPEN + } + return createComment(e, &CreateCommentOptions{ + Type: cmtType, + Doer: doer, + Repo: repo, + Issue: issue, + }) +} + +type CreateCommentOptions struct { + Type CommentType + Doer *User + Repo *Repository + Issue *Issue + + CommitID int64 + CommitSHA string + LineNum int64 + Content string + Attachments []string // UUIDs of attachments +} + +// CreateComment creates comment of issue or commit. +func CreateComment(opts *CreateCommentOptions) (comment *Comment, err error) { + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return nil, err + } + + comment, err = createComment(sess, opts) + if err != nil { + return nil, err + } + + return comment, sess.Commit() +} + +// CreateIssueComment creates a plain issue comment. +func CreateIssueComment(doer *User, repo *Repository, issue *Issue, content string, attachments []string) (*Comment, error) { + comment, err := CreateComment(&CreateCommentOptions{ + Type: COMMENT_TYPE_COMMENT, + Doer: doer, + Repo: repo, + Issue: issue, + Content: content, + Attachments: attachments, + }) + if err != nil { + return nil, fmt.Errorf("CreateComment: %v", err) + } + + comment.Issue = issue + if err = PrepareWebhooks(repo, HOOK_EVENT_ISSUE_COMMENT, &api.IssueCommentPayload{ + Action: api.HOOK_ISSUE_COMMENT_CREATED, + Issue: issue.APIFormat(), + Comment: comment.APIFormat(), + Repository: repo.APIFormat(nil), + Sender: doer.APIFormat(), + }); err != nil { + log.Error(2, "PrepareWebhooks [comment_id: %d]: %v", comment.ID, err) + } + + return comment, nil +} + +// CreateRefComment creates a commit reference comment to issue. +func CreateRefComment(doer *User, repo *Repository, issue *Issue, content, commitSHA string) error { + if len(commitSHA) == 0 { + return fmt.Errorf("cannot create reference with empty commit SHA") + } + + // Check if same reference from same commit has already existed. + has, err := x.Get(&Comment{ + Type: COMMENT_TYPE_COMMIT_REF, + IssueID: issue.ID, + CommitSHA: commitSHA, + }) + if err != nil { + return fmt.Errorf("check reference comment: %v", err) + } else if has { + return nil + } + + _, err = CreateComment(&CreateCommentOptions{ + Type: COMMENT_TYPE_COMMIT_REF, + Doer: doer, + Repo: repo, + Issue: issue, + CommitSHA: commitSHA, + Content: content, + }) + return err +} + +// GetCommentByID returns the comment by given ID. +func GetCommentByID(id int64) (*Comment, error) { + c := new(Comment) + has, err := x.Id(id).Get(c) + if err != nil { + return nil, err + } else if !has { + return nil, ErrCommentNotExist{id, 0} + } + return c, c.LoadAttributes() +} + +// FIXME: use CommentList to improve performance. +func loadCommentsAttributes(e Engine, comments []*Comment) (err error) { + for i := range comments { + if err = comments[i].loadAttributes(e); err != nil { + return fmt.Errorf("loadAttributes [%d]: %v", comments[i].ID, err) + } + } + + return nil +} + +func getCommentsByIssueIDSince(e Engine, issueID, since int64) ([]*Comment, error) { + comments := make([]*Comment, 0, 10) + sess := e.Where("issue_id = ?", issueID).Asc("created_unix") + if since > 0 { + sess.And("updated_unix >= ?", since) + } + + if err := sess.Find(&comments); err != nil { + return nil, err + } + return comments, loadCommentsAttributes(e, comments) +} + +func getCommentsByRepoIDSince(e Engine, repoID, since int64) ([]*Comment, error) { + comments := make([]*Comment, 0, 10) + sess := e.Where("issue.repo_id = ?", repoID).Join("INNER", "issue", "issue.id = comment.issue_id").Asc("comment.created_unix") + if since > 0 { + sess.And("comment.updated_unix >= ?", since) + } + if err := sess.Find(&comments); err != nil { + return nil, err + } + return comments, loadCommentsAttributes(e, comments) +} + +func getCommentsByIssueID(e Engine, issueID int64) ([]*Comment, error) { + return getCommentsByIssueIDSince(e, issueID, -1) +} + +// GetCommentsByIssueID returns all comments of an issue. +func GetCommentsByIssueID(issueID int64) ([]*Comment, error) { + return getCommentsByIssueID(x, issueID) +} + +// GetCommentsByIssueIDSince returns a list of comments of an issue since a given time point. +func GetCommentsByIssueIDSince(issueID, since int64) ([]*Comment, error) { + return getCommentsByIssueIDSince(x, issueID, since) +} + +// GetCommentsByRepoIDSince returns a list of comments for all issues in a repo since a given time point. +func GetCommentsByRepoIDSince(repoID, since int64) ([]*Comment, error) { + return getCommentsByRepoIDSince(x, repoID, since) +} + +// UpdateComment updates information of comment. +func UpdateComment(doer *User, c *Comment, oldContent string) (err error) { + if _, err = x.Id(c.ID).AllCols().Update(c); err != nil { + return err + } + + if err = c.Issue.LoadAttributes(); err != nil { + log.Error(2, "Issue.LoadAttributes [issue_id: %d]: %v", c.IssueID, err) + } else if err = PrepareWebhooks(c.Issue.Repo, HOOK_EVENT_ISSUE_COMMENT, &api.IssueCommentPayload{ + Action: api.HOOK_ISSUE_COMMENT_EDITED, + Issue: c.Issue.APIFormat(), + Comment: c.APIFormat(), + Changes: &api.ChangesPayload{ + Body: &api.ChangesFromPayload{ + From: oldContent, + }, + }, + Repository: c.Issue.Repo.APIFormat(nil), + Sender: doer.APIFormat(), + }); err != nil { + log.Error(2, "PrepareWebhooks [comment_id: %d]: %v", c.ID, err) + } + + return nil +} + +// DeleteCommentByID deletes the comment by given ID. +func DeleteCommentByID(doer *User, id int64) error { + comment, err := GetCommentByID(id) + if err != nil { + if IsErrCommentNotExist(err) { + return nil + } + return err + } + + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return err + } + + if _, err = sess.ID(comment.ID).Delete(new(Comment)); err != nil { + return err + } + + if comment.Type == COMMENT_TYPE_COMMENT { + if _, err = sess.Exec("UPDATE `issue` SET num_comments = num_comments - 1 WHERE id = ?", comment.IssueID); err != nil { + return err + } + } + + if err = sess.Commit(); err != nil { + return fmt.Errorf("commit: %v", err) + } + + _, err = DeleteAttachmentsByComment(comment.ID, true) + if err != nil { + log.Error(2, "Failed to delete attachments by comment[%d]: %v", comment.ID, err) + } + + if err = comment.Issue.LoadAttributes(); err != nil { + log.Error(2, "Issue.LoadAttributes [issue_id: %d]: %v", comment.IssueID, err) + } else if err = PrepareWebhooks(comment.Issue.Repo, HOOK_EVENT_ISSUE_COMMENT, &api.IssueCommentPayload{ + Action: api.HOOK_ISSUE_COMMENT_DELETED, + Issue: comment.Issue.APIFormat(), + Comment: comment.APIFormat(), + Repository: comment.Issue.Repo.APIFormat(nil), + Sender: doer.APIFormat(), + }); err != nil { + log.Error(2, "PrepareWebhooks [comment_id: %d]: %v", comment.ID, err) + } + return nil +} diff --git a/internal/db/error.go b/internal/db/error.go new file mode 100644 index 00000000..033d631b --- /dev/null +++ b/internal/db/error.go @@ -0,0 +1,575 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package db + +import ( + "fmt" +) + +type ErrNameReserved struct { + Name string +} + +func IsErrNameReserved(err error) bool { + _, ok := err.(ErrNameReserved) + return ok +} + +func (err ErrNameReserved) Error() string { + return fmt.Sprintf("name is reserved [name: %s]", err.Name) +} + +type ErrNamePatternNotAllowed struct { + Pattern string +} + +func IsErrNamePatternNotAllowed(err error) bool { + _, ok := err.(ErrNamePatternNotAllowed) + return ok +} + +func (err ErrNamePatternNotAllowed) Error() string { + return fmt.Sprintf("name pattern is not allowed [pattern: %s]", err.Pattern) +} + +// ____ ___ +// | | \______ ___________ +// | | / ___// __ \_ __ \ +// | | /\___ \\ ___/| | \/ +// |______//____ >\___ >__| +// \/ \/ + +type ErrUserAlreadyExist struct { + Name string +} + +func IsErrUserAlreadyExist(err error) bool { + _, ok := err.(ErrUserAlreadyExist) + return ok +} + +func (err ErrUserAlreadyExist) Error() string { + return fmt.Sprintf("user already exists [name: %s]", err.Name) +} + +type ErrEmailAlreadyUsed struct { + Email string +} + +func IsErrEmailAlreadyUsed(err error) bool { + _, ok := err.(ErrEmailAlreadyUsed) + return ok +} + +func (err ErrEmailAlreadyUsed) Error() string { + return fmt.Sprintf("e-mail has been used [email: %s]", err.Email) +} + +type ErrUserOwnRepos struct { + UID int64 +} + +func IsErrUserOwnRepos(err error) bool { + _, ok := err.(ErrUserOwnRepos) + return ok +} + +func (err ErrUserOwnRepos) Error() string { + return fmt.Sprintf("user still has ownership of repositories [uid: %d]", err.UID) +} + +type ErrUserHasOrgs struct { + UID int64 +} + +func IsErrUserHasOrgs(err error) bool { + _, ok := err.(ErrUserHasOrgs) + return ok +} + +func (err ErrUserHasOrgs) Error() string { + return fmt.Sprintf("user still has membership of organizations [uid: %d]", err.UID) +} + +// __ __.__ __ .__ +// / \ / \__| | _|__| +// \ \/\/ / | |/ / | +// \ /| | <| | +// \__/\ / |__|__|_ \__| +// \/ \/ + +type ErrWikiAlreadyExist struct { + Title string +} + +func IsErrWikiAlreadyExist(err error) bool { + _, ok := err.(ErrWikiAlreadyExist) + return ok +} + +func (err ErrWikiAlreadyExist) Error() string { + return fmt.Sprintf("wiki page already exists [title: %s]", err.Title) +} + +// __________ ___. .__ .__ ____ __. +// \______ \__ _\_ |__ | | |__| ____ | |/ _|____ ___.__. +// | ___/ | \ __ \| | | |/ ___\ | <_/ __ < | | +// | | | | / \_\ \ |_| \ \___ | | \ ___/\___ | +// |____| |____/|___ /____/__|\___ > |____|__ \___ > ____| +// \/ \/ \/ \/\/ + +type ErrKeyUnableVerify struct { + Result string +} + +func IsErrKeyUnableVerify(err error) bool { + _, ok := err.(ErrKeyUnableVerify) + return ok +} + +func (err ErrKeyUnableVerify) Error() string { + return fmt.Sprintf("Unable to verify key content [result: %s]", err.Result) +} + +type ErrKeyNotExist struct { + ID int64 +} + +func IsErrKeyNotExist(err error) bool { + _, ok := err.(ErrKeyNotExist) + return ok +} + +func (err ErrKeyNotExist) Error() string { + return fmt.Sprintf("public key does not exist [id: %d]", err.ID) +} + +type ErrKeyAlreadyExist struct { + OwnerID int64 + Content string +} + +func IsErrKeyAlreadyExist(err error) bool { + _, ok := err.(ErrKeyAlreadyExist) + return ok +} + +func (err ErrKeyAlreadyExist) Error() string { + return fmt.Sprintf("public key already exists [owner_id: %d, content: %s]", err.OwnerID, err.Content) +} + +type ErrKeyNameAlreadyUsed struct { + OwnerID int64 + Name string +} + +func IsErrKeyNameAlreadyUsed(err error) bool { + _, ok := err.(ErrKeyNameAlreadyUsed) + return ok +} + +func (err ErrKeyNameAlreadyUsed) Error() string { + return fmt.Sprintf("public key already exists [owner_id: %d, name: %s]", err.OwnerID, err.Name) +} + +type ErrKeyAccessDenied struct { + UserID int64 + KeyID int64 + Note string +} + +func IsErrKeyAccessDenied(err error) bool { + _, ok := err.(ErrKeyAccessDenied) + return ok +} + +func (err ErrKeyAccessDenied) Error() string { + return fmt.Sprintf("user does not have access to the key [user_id: %d, key_id: %d, note: %s]", + err.UserID, err.KeyID, err.Note) +} + +type ErrDeployKeyNotExist struct { + ID int64 + KeyID int64 + RepoID int64 +} + +func IsErrDeployKeyNotExist(err error) bool { + _, ok := err.(ErrDeployKeyNotExist) + return ok +} + +func (err ErrDeployKeyNotExist) Error() string { + return fmt.Sprintf("Deploy key does not exist [id: %d, key_id: %d, repo_id: %d]", err.ID, err.KeyID, err.RepoID) +} + +type ErrDeployKeyAlreadyExist struct { + KeyID int64 + RepoID int64 +} + +func IsErrDeployKeyAlreadyExist(err error) bool { + _, ok := err.(ErrDeployKeyAlreadyExist) + return ok +} + +func (err ErrDeployKeyAlreadyExist) Error() string { + return fmt.Sprintf("public key already exists [key_id: %d, repo_id: %d]", err.KeyID, err.RepoID) +} + +type ErrDeployKeyNameAlreadyUsed struct { + RepoID int64 + Name string +} + +func IsErrDeployKeyNameAlreadyUsed(err error) bool { + _, ok := err.(ErrDeployKeyNameAlreadyUsed) + return ok +} + +func (err ErrDeployKeyNameAlreadyUsed) Error() string { + return fmt.Sprintf("public key already exists [repo_id: %d, name: %s]", err.RepoID, err.Name) +} + +// _____ ___________ __ +// / _ \ ____ ____ ____ ______ _____\__ ___/___ | | __ ____ ____ +// / /_\ \_/ ___\/ ___\/ __ \ / ___// ___/ | | / _ \| |/ // __ \ / \ +// / | \ \__\ \__\ ___/ \___ \ \___ \ | |( <_> ) <\ ___/| | \ +// \____|__ /\___ >___ >___ >____ >____ > |____| \____/|__|_ \\___ >___| / +// \/ \/ \/ \/ \/ \/ \/ \/ \/ + +type ErrAccessTokenNotExist struct { + SHA string +} + +func IsErrAccessTokenNotExist(err error) bool { + _, ok := err.(ErrAccessTokenNotExist) + return ok +} + +func (err ErrAccessTokenNotExist) Error() string { + return fmt.Sprintf("access token does not exist [sha: %s]", err.SHA) +} + +type ErrAccessTokenEmpty struct { +} + +func IsErrAccessTokenEmpty(err error) bool { + _, ok := err.(ErrAccessTokenEmpty) + return ok +} + +func (err ErrAccessTokenEmpty) Error() string { + return fmt.Sprintf("access token is empty") +} + +// ________ .__ __ .__ +// \_____ \_______ _________ ____ |__|____________ _/ |_|__| ____ ____ +// / | \_ __ \/ ___\__ \ / \| \___ /\__ \\ __\ |/ _ \ / \ +// / | \ | \/ /_/ > __ \| | \ |/ / / __ \| | | ( <_> ) | \ +// \_______ /__| \___ (____ /___| /__/_____ \(____ /__| |__|\____/|___| / +// \/ /_____/ \/ \/ \/ \/ \/ + +type ErrLastOrgOwner struct { + UID int64 +} + +func IsErrLastOrgOwner(err error) bool { + _, ok := err.(ErrLastOrgOwner) + return ok +} + +func (err ErrLastOrgOwner) Error() string { + return fmt.Sprintf("user is the last member of owner team [uid: %d]", err.UID) +} + +// __________ .__ __ +// \______ \ ____ ______ ____ _____|__|/ |_ ___________ ___.__. +// | _// __ \\____ \ / _ \/ ___/ \ __\/ _ \_ __ < | | +// | | \ ___/| |_> > <_> )___ \| || | ( <_> ) | \/\___ | +// |____|_ /\___ > __/ \____/____ >__||__| \____/|__| / ____| +// \/ \/|__| \/ \/ + +type ErrRepoAlreadyExist struct { + Uname string + Name string +} + +func IsErrRepoAlreadyExist(err error) bool { + _, ok := err.(ErrRepoAlreadyExist) + return ok +} + +func (err ErrRepoAlreadyExist) Error() string { + return fmt.Sprintf("repository already exists [uname: %s, name: %s]", err.Uname, err.Name) +} + +type ErrInvalidCloneAddr struct { + IsURLError bool + IsInvalidPath bool + IsPermissionDenied bool +} + +func IsErrInvalidCloneAddr(err error) bool { + _, ok := err.(ErrInvalidCloneAddr) + return ok +} + +func (err ErrInvalidCloneAddr) Error() string { + return fmt.Sprintf("invalid clone address [is_url_error: %v, is_invalid_path: %v, is_permission_denied: %v]", + err.IsURLError, err.IsInvalidPath, err.IsPermissionDenied) +} + +type ErrUpdateTaskNotExist struct { + UUID string +} + +func IsErrUpdateTaskNotExist(err error) bool { + _, ok := err.(ErrUpdateTaskNotExist) + return ok +} + +func (err ErrUpdateTaskNotExist) Error() string { + return fmt.Sprintf("update task does not exist [uuid: %s]", err.UUID) +} + +type ErrReleaseAlreadyExist struct { + TagName string +} + +func IsErrReleaseAlreadyExist(err error) bool { + _, ok := err.(ErrReleaseAlreadyExist) + return ok +} + +func (err ErrReleaseAlreadyExist) Error() string { + return fmt.Sprintf("release tag already exist [tag_name: %s]", err.TagName) +} + +type ErrReleaseNotExist struct { + ID int64 + TagName string +} + +func IsErrReleaseNotExist(err error) bool { + _, ok := err.(ErrReleaseNotExist) + return ok +} + +func (err ErrReleaseNotExist) Error() string { + return fmt.Sprintf("release tag does not exist [id: %d, tag_name: %s]", err.ID, err.TagName) +} + +type ErrInvalidTagName struct { + TagName string +} + +func IsErrInvalidTagName(err error) bool { + _, ok := err.(ErrInvalidTagName) + return ok +} + +func (err ErrInvalidTagName) Error() string { + return fmt.Sprintf("release tag name is not valid [tag_name: %s]", err.TagName) +} + +type ErrRepoFileAlreadyExist struct { + FileName string +} + +func IsErrRepoFileAlreadyExist(err error) bool { + _, ok := err.(ErrRepoFileAlreadyExist) + return ok +} + +func (err ErrRepoFileAlreadyExist) Error() string { + return fmt.Sprintf("repository file already exists [file_name: %s]", err.FileName) +} + +// __________ .__ .__ __________ __ +// \______ \__ __| | | |\______ \ ____ ________ __ ____ _______/ |_ +// | ___/ | \ | | | | _// __ \/ ____/ | \_/ __ \ / ___/\ __\ +// | | | | / |_| |_| | \ ___< <_| | | /\ ___/ \___ \ | | +// |____| |____/|____/____/____|_ /\___ >__ |____/ \___ >____ > |__| +// \/ \/ |__| \/ \/ + +type ErrPullRequestNotExist struct { + ID int64 + IssueID int64 + HeadRepoID int64 + BaseRepoID int64 + HeadBarcnh string + BaseBranch string +} + +func IsErrPullRequestNotExist(err error) bool { + _, ok := err.(ErrPullRequestNotExist) + return ok +} + +func (err ErrPullRequestNotExist) Error() string { + return fmt.Sprintf("pull request does not exist [id: %d, issue_id: %d, head_repo_id: %d, base_repo_id: %d, head_branch: %s, base_branch: %s]", + err.ID, err.IssueID, err.HeadRepoID, err.BaseRepoID, err.HeadBarcnh, err.BaseBranch) +} + +// _________ __ +// \_ ___ \ ____ _____ _____ ____ _____/ |_ +// / \ \/ / _ \ / \ / \_/ __ \ / \ __\ +// \ \___( <_> ) Y Y \ Y Y \ ___/| | \ | +// \______ /\____/|__|_| /__|_| /\___ >___| /__| +// \/ \/ \/ \/ \/ + +type ErrCommentNotExist struct { + ID int64 + IssueID int64 +} + +func IsErrCommentNotExist(err error) bool { + _, ok := err.(ErrCommentNotExist) + return ok +} + +func (err ErrCommentNotExist) Error() string { + return fmt.Sprintf("comment does not exist [id: %d, issue_id: %d]", err.ID, err.IssueID) +} + +// .____ ___. .__ +// | | _____ \_ |__ ____ | | +// | | \__ \ | __ \_/ __ \| | +// | |___ / __ \| \_\ \ ___/| |__ +// |_______ (____ /___ /\___ >____/ +// \/ \/ \/ \/ + +type ErrLabelNotExist struct { + LabelID int64 + RepoID int64 +} + +func IsErrLabelNotExist(err error) bool { + _, ok := err.(ErrLabelNotExist) + return ok +} + +func (err ErrLabelNotExist) Error() string { + return fmt.Sprintf("label does not exist [label_id: %d, repo_id: %d]", err.LabelID, err.RepoID) +} + +// _____ .__.__ __ +// / \ |__| | ____ _______/ |_ ____ ____ ____ +// / \ / \| | | _/ __ \ / ___/\ __\/ _ \ / \_/ __ \ +// / Y \ | |_\ ___/ \___ \ | | ( <_> ) | \ ___/ +// \____|__ /__|____/\___ >____ > |__| \____/|___| /\___ > +// \/ \/ \/ \/ \/ + +type ErrMilestoneNotExist struct { + ID int64 + RepoID int64 +} + +func IsErrMilestoneNotExist(err error) bool { + _, ok := err.(ErrMilestoneNotExist) + return ok +} + +func (err ErrMilestoneNotExist) Error() string { + return fmt.Sprintf("milestone does not exist [id: %d, repo_id: %d]", err.ID, err.RepoID) +} + +// _____ __ __ .__ __ +// / _ \_/ |__/ |______ ____ | |__ _____ ____ _____/ |_ +// / /_\ \ __\ __\__ \ _/ ___\| | \ / \_/ __ \ / \ __\ +// / | \ | | | / __ \\ \___| Y \ Y Y \ ___/| | \ | +// \____|__ /__| |__| (____ /\___ >___| /__|_| /\___ >___| /__| +// \/ \/ \/ \/ \/ \/ \/ + +type ErrAttachmentNotExist struct { + ID int64 + UUID string +} + +func IsErrAttachmentNotExist(err error) bool { + _, ok := err.(ErrAttachmentNotExist) + return ok +} + +func (err ErrAttachmentNotExist) Error() string { + return fmt.Sprintf("attachment does not exist [id: %d, uuid: %s]", err.ID, err.UUID) +} + +// .____ .__ _________ +// | | ____ ____ |__| ____ / _____/ ____ __ _________ ____ ____ +// | | / _ \ / ___\| |/ \ \_____ \ / _ \| | \_ __ \_/ ___\/ __ \ +// | |__( <_> ) /_/ > | | \ / ( <_> ) | /| | \/\ \__\ ___/ +// |_______ \____/\___ /|__|___| / /_______ /\____/|____/ |__| \___ >___ > +// \/ /_____/ \/ \/ \/ \/ + +type ErrLoginSourceAlreadyExist struct { + Name string +} + +func IsErrLoginSourceAlreadyExist(err error) bool { + _, ok := err.(ErrLoginSourceAlreadyExist) + return ok +} + +func (err ErrLoginSourceAlreadyExist) Error() string { + return fmt.Sprintf("login source already exists [name: %s]", err.Name) +} + +type ErrLoginSourceInUse struct { + ID int64 +} + +func IsErrLoginSourceInUse(err error) bool { + _, ok := err.(ErrLoginSourceInUse) + return ok +} + +func (err ErrLoginSourceInUse) Error() string { + return fmt.Sprintf("login source is still used by some users [id: %d]", err.ID) +} + +// ___________ +// \__ ___/___ _____ _____ +// | |_/ __ \\__ \ / \ +// | |\ ___/ / __ \| Y Y \ +// |____| \___ >____ /__|_| / +// \/ \/ \/ + +type ErrTeamAlreadyExist struct { + OrgID int64 + Name string +} + +func IsErrTeamAlreadyExist(err error) bool { + _, ok := err.(ErrTeamAlreadyExist) + return ok +} + +func (err ErrTeamAlreadyExist) Error() string { + return fmt.Sprintf("team already exists [org_id: %d, name: %s]", err.OrgID, err.Name) +} + +// ____ ___ .__ .___ +// | | \______ | | _________ __| _/ +// | | /\____ \| | / _ \__ \ / __ | +// | | / | |_> > |_( <_> ) __ \_/ /_/ | +// |______/ | __/|____/\____(____ /\____ | +// |__| \/ \/ +// + +type ErrUploadNotExist struct { + ID int64 + UUID string +} + +func IsErrUploadNotExist(err error) bool { + _, ok := err.(ErrAttachmentNotExist) + return ok +} + +func (err ErrUploadNotExist) Error() string { + return fmt.Sprintf("attachment does not exist [id: %d, uuid: %s]", err.ID, err.UUID) +} diff --git a/internal/db/errors/errors.go b/internal/db/errors/errors.go new file mode 100644 index 00000000..cc231436 --- /dev/null +++ b/internal/db/errors/errors.go @@ -0,0 +1,14 @@ +// Copyright 2017 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package errors + +import "errors" + +var InternalServerError = errors.New("internal server error") + +// New is a wrapper of real errors.New function. +func New(text string) error { + return errors.New(text) +} diff --git a/internal/db/errors/issue.go b/internal/db/errors/issue.go new file mode 100644 index 00000000..903cc977 --- /dev/null +++ b/internal/db/errors/issue.go @@ -0,0 +1,35 @@ +// Copyright 2017 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package errors + +import "fmt" + +type IssueNotExist struct { + ID int64 + RepoID int64 + Index int64 +} + +func IsIssueNotExist(err error) bool { + _, ok := err.(IssueNotExist) + return ok +} + +func (err IssueNotExist) Error() string { + return fmt.Sprintf("issue does not exist [id: %d, repo_id: %d, index: %d]", err.ID, err.RepoID, err.Index) +} + +type InvalidIssueReference struct { + Ref string +} + +func IsInvalidIssueReference(err error) bool { + _, ok := err.(InvalidIssueReference) + return ok +} + +func (err InvalidIssueReference) Error() string { + return fmt.Sprintf("invalid issue reference [ref: %s]", err.Ref) +} diff --git a/internal/db/errors/login_source.go b/internal/db/errors/login_source.go new file mode 100644 index 00000000..dd18664e --- /dev/null +++ b/internal/db/errors/login_source.go @@ -0,0 +1,60 @@ +// Copyright 2017 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package errors + +import "fmt" + +type LoginSourceNotExist struct { + ID int64 +} + +func IsLoginSourceNotExist(err error) bool { + _, ok := err.(LoginSourceNotExist) + return ok +} + +func (err LoginSourceNotExist) Error() string { + return fmt.Sprintf("login source does not exist [id: %d]", err.ID) +} + +type LoginSourceNotActivated struct { + SourceID int64 +} + +func IsLoginSourceNotActivated(err error) bool { + _, ok := err.(LoginSourceNotActivated) + return ok +} + +func (err LoginSourceNotActivated) Error() string { + return fmt.Sprintf("login source is not activated [source_id: %d]", err.SourceID) +} + +type InvalidLoginSourceType struct { + Type interface{} +} + +func IsInvalidLoginSourceType(err error) bool { + _, ok := err.(InvalidLoginSourceType) + return ok +} + +func (err InvalidLoginSourceType) Error() string { + return fmt.Sprintf("invalid login source type [type: %v]", err.Type) +} + +type LoginSourceMismatch struct { + Expect int64 + Actual int64 +} + +func IsLoginSourceMismatch(err error) bool { + _, ok := err.(LoginSourceMismatch) + return ok +} + +func (err LoginSourceMismatch) Error() string { + return fmt.Sprintf("login source mismatch [expect: %d, actual: %d]", err.Expect, err.Actual) +} diff --git a/internal/db/errors/org.go b/internal/db/errors/org.go new file mode 100644 index 00000000..56532746 --- /dev/null +++ b/internal/db/errors/org.go @@ -0,0 +1,21 @@ +// Copyright 2018 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package errors + +import "fmt" + +type TeamNotExist struct { + TeamID int64 + Name string +} + +func IsTeamNotExist(err error) bool { + _, ok := err.(TeamNotExist) + return ok +} + +func (err TeamNotExist) Error() string { + return fmt.Sprintf("team does not exist [team_id: %d, name: %s]", err.TeamID, err.Name) +} diff --git a/internal/db/errors/repo.go b/internal/db/errors/repo.go new file mode 100644 index 00000000..c9894af9 --- /dev/null +++ b/internal/db/errors/repo.go @@ -0,0 +1,87 @@ +// Copyright 2017 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package errors + +import "fmt" + +type RepoNotExist struct { + ID int64 + UserID int64 + Name string +} + +func IsRepoNotExist(err error) bool { + _, ok := err.(RepoNotExist) + return ok +} + +func (err RepoNotExist) Error() string { + return fmt.Sprintf("repository does not exist [id: %d, user_id: %d, name: %s]", err.ID, err.UserID, err.Name) +} + +type ReachLimitOfRepo struct { + Limit int +} + +func IsReachLimitOfRepo(err error) bool { + _, ok := err.(ReachLimitOfRepo) + return ok +} + +func (err ReachLimitOfRepo) Error() string { + return fmt.Sprintf("user has reached maximum limit of repositories [limit: %d]", err.Limit) +} + +type InvalidRepoReference struct { + Ref string +} + +func IsInvalidRepoReference(err error) bool { + _, ok := err.(InvalidRepoReference) + return ok +} + +func (err InvalidRepoReference) Error() string { + return fmt.Sprintf("invalid repository reference [ref: %s]", err.Ref) +} + +type MirrorNotExist struct { + RepoID int64 +} + +func IsMirrorNotExist(err error) bool { + _, ok := err.(MirrorNotExist) + return ok +} + +func (err MirrorNotExist) Error() string { + return fmt.Sprintf("mirror does not exist [repo_id: %d]", err.RepoID) +} + +type BranchAlreadyExists struct { + Name string +} + +func IsBranchAlreadyExists(err error) bool { + _, ok := err.(BranchAlreadyExists) + return ok +} + +func (err BranchAlreadyExists) Error() string { + return fmt.Sprintf("branch already exists [name: %s]", err.Name) +} + +type ErrBranchNotExist struct { + Name string +} + +func IsErrBranchNotExist(err error) bool { + _, ok := err.(ErrBranchNotExist) + return ok +} + +func (err ErrBranchNotExist) Error() string { + return fmt.Sprintf("branch does not exist [name: %s]", err.Name) +} diff --git a/internal/db/errors/token.go b/internal/db/errors/token.go new file mode 100644 index 00000000..d6a4577a --- /dev/null +++ b/internal/db/errors/token.go @@ -0,0 +1,16 @@ +package errors + +import "fmt" + +type AccessTokenNameAlreadyExist struct { + Name string +} + +func IsAccessTokenNameAlreadyExist(err error) bool { + _, ok := err.(AccessTokenNameAlreadyExist) + return ok +} + +func (err AccessTokenNameAlreadyExist) Error() string { + return fmt.Sprintf("access token already exist [name: %s]", err.Name) +} diff --git a/internal/db/errors/two_factor.go b/internal/db/errors/two_factor.go new file mode 100644 index 00000000..02cdcf5c --- /dev/null +++ b/internal/db/errors/two_factor.go @@ -0,0 +1,33 @@ +// Copyright 2017 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package errors + +import "fmt" + +type TwoFactorNotFound struct { + UserID int64 +} + +func IsTwoFactorNotFound(err error) bool { + _, ok := err.(TwoFactorNotFound) + return ok +} + +func (err TwoFactorNotFound) Error() string { + return fmt.Sprintf("two-factor authentication does not found [user_id: %d]", err.UserID) +} + +type TwoFactorRecoveryCodeNotFound struct { + Code string +} + +func IsTwoFactorRecoveryCodeNotFound(err error) bool { + _, ok := err.(TwoFactorRecoveryCodeNotFound) + return ok +} + +func (err TwoFactorRecoveryCodeNotFound) Error() string { + return fmt.Sprintf("two-factor recovery code does not found [code: %s]", err.Code) +} diff --git a/internal/db/errors/user.go b/internal/db/errors/user.go new file mode 100644 index 00000000..526d4b2d --- /dev/null +++ b/internal/db/errors/user.go @@ -0,0 +1,45 @@ +// Copyright 2017 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package errors + +import "fmt" + +type EmptyName struct{} + +func IsEmptyName(err error) bool { + _, ok := err.(EmptyName) + return ok +} + +func (err EmptyName) Error() string { + return "empty name" +} + +type UserNotExist struct { + UserID int64 + Name string +} + +func IsUserNotExist(err error) bool { + _, ok := err.(UserNotExist) + return ok +} + +func (err UserNotExist) Error() string { + return fmt.Sprintf("user does not exist [user_id: %d, name: %s]", err.UserID, err.Name) +} + +type UserNotKeyOwner struct { + KeyID int64 +} + +func IsUserNotKeyOwner(err error) bool { + _, ok := err.(UserNotKeyOwner) + return ok +} + +func (err UserNotKeyOwner) Error() string { + return fmt.Sprintf("user is not the owner of public key [key_id: %d]", err.KeyID) +} diff --git a/internal/db/errors/user_mail.go b/internal/db/errors/user_mail.go new file mode 100644 index 00000000..fcdeb78c --- /dev/null +++ b/internal/db/errors/user_mail.go @@ -0,0 +1,33 @@ +// Copyright 2017 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package errors + +import "fmt" + +type EmailNotFound struct { + Email string +} + +func IsEmailNotFound(err error) bool { + _, ok := err.(EmailNotFound) + return ok +} + +func (err EmailNotFound) Error() string { + return fmt.Sprintf("email is not found [email: %s]", err.Email) +} + +type EmailNotVerified struct { + Email string +} + +func IsEmailNotVerified(err error) bool { + _, ok := err.(EmailNotVerified) + return ok +} + +func (err EmailNotVerified) Error() string { + return fmt.Sprintf("email has not been verified [email: %s]", err.Email) +} diff --git a/internal/db/errors/webhook.go b/internal/db/errors/webhook.go new file mode 100644 index 00000000..76cf8cb4 --- /dev/null +++ b/internal/db/errors/webhook.go @@ -0,0 +1,34 @@ +// Copyright 2017 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package errors + +import "fmt" + +type WebhookNotExist struct { + ID int64 +} + +func IsWebhookNotExist(err error) bool { + _, ok := err.(WebhookNotExist) + return ok +} + +func (err WebhookNotExist) Error() string { + return fmt.Sprintf("webhook does not exist [id: %d]", err.ID) +} + +type HookTaskNotExist struct { + HookID int64 + UUID string +} + +func IsHookTaskNotExist(err error) bool { + _, ok := err.(HookTaskNotExist) + return ok +} + +func (err HookTaskNotExist) Error() string { + return fmt.Sprintf("hook task does not exist [hook_id: %d, uuid: %s]", err.HookID, err.UUID) +} diff --git a/internal/db/git_diff.go b/internal/db/git_diff.go new file mode 100644 index 00000000..040c472b --- /dev/null +++ b/internal/db/git_diff.go @@ -0,0 +1,194 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package db + +import ( + "bytes" + "fmt" + "html" + "html/template" + "io" + + "github.com/sergi/go-diff/diffmatchpatch" + "golang.org/x/net/html/charset" + "golang.org/x/text/transform" + + "github.com/gogs/git-module" + + "gogs.io/gogs/internal/setting" + "gogs.io/gogs/internal/template/highlight" + "gogs.io/gogs/internal/tool" +) + +type DiffSection struct { + *git.DiffSection +} + +var ( + addedCodePrefix = []byte("<span class=\"added-code\">") + removedCodePrefix = []byte("<span class=\"removed-code\">") + codeTagSuffix = []byte("</span>") +) + +func diffToHTML(diffs []diffmatchpatch.Diff, lineType git.DiffLineType) template.HTML { + buf := bytes.NewBuffer(nil) + + // Reproduce signs which are cutted for inline diff before. + switch lineType { + case git.DIFF_LINE_ADD: + buf.WriteByte('+') + case git.DIFF_LINE_DEL: + buf.WriteByte('-') + } + + for i := range diffs { + switch { + case diffs[i].Type == diffmatchpatch.DiffInsert && lineType == git.DIFF_LINE_ADD: + buf.Write(addedCodePrefix) + buf.WriteString(html.EscapeString(diffs[i].Text)) + buf.Write(codeTagSuffix) + case diffs[i].Type == diffmatchpatch.DiffDelete && lineType == git.DIFF_LINE_DEL: + buf.Write(removedCodePrefix) + buf.WriteString(html.EscapeString(diffs[i].Text)) + buf.Write(codeTagSuffix) + case diffs[i].Type == diffmatchpatch.DiffEqual: + buf.WriteString(html.EscapeString(diffs[i].Text)) + } + } + + return template.HTML(buf.Bytes()) +} + +var diffMatchPatch = diffmatchpatch.New() + +func init() { + diffMatchPatch.DiffEditCost = 100 +} + +// ComputedInlineDiffFor computes inline diff for the given line. +func (diffSection *DiffSection) ComputedInlineDiffFor(diffLine *git.DiffLine) template.HTML { + if setting.Git.DisableDiffHighlight { + return template.HTML(html.EscapeString(diffLine.Content[1:])) + } + var ( + compareDiffLine *git.DiffLine + diff1 string + diff2 string + ) + + // try to find equivalent diff line. ignore, otherwise + switch diffLine.Type { + case git.DIFF_LINE_ADD: + compareDiffLine = diffSection.Line(git.DIFF_LINE_DEL, diffLine.RightIdx) + if compareDiffLine == nil { + return template.HTML(html.EscapeString(diffLine.Content)) + } + diff1 = compareDiffLine.Content + diff2 = diffLine.Content + case git.DIFF_LINE_DEL: + compareDiffLine = diffSection.Line(git.DIFF_LINE_ADD, diffLine.LeftIdx) + if compareDiffLine == nil { + return template.HTML(html.EscapeString(diffLine.Content)) + } + diff1 = diffLine.Content + diff2 = compareDiffLine.Content + default: + return template.HTML(html.EscapeString(diffLine.Content)) + } + + diffRecord := diffMatchPatch.DiffMain(diff1[1:], diff2[1:], true) + diffRecord = diffMatchPatch.DiffCleanupEfficiency(diffRecord) + + return diffToHTML(diffRecord, diffLine.Type) +} + +type DiffFile struct { + *git.DiffFile + Sections []*DiffSection +} + +func (diffFile *DiffFile) HighlightClass() string { + return highlight.FileNameToHighlightClass(diffFile.Name) +} + +type Diff struct { + *git.Diff + Files []*DiffFile +} + +func NewDiff(gitDiff *git.Diff) *Diff { + diff := &Diff{ + Diff: gitDiff, + Files: make([]*DiffFile, gitDiff.NumFiles()), + } + + // FIXME: detect encoding while parsing. + var buf bytes.Buffer + for i := range gitDiff.Files { + buf.Reset() + + diff.Files[i] = &DiffFile{ + DiffFile: gitDiff.Files[i], + Sections: make([]*DiffSection, gitDiff.Files[i].NumSections()), + } + + for j := range gitDiff.Files[i].Sections { + diff.Files[i].Sections[j] = &DiffSection{ + DiffSection: gitDiff.Files[i].Sections[j], + } + + for k := range diff.Files[i].Sections[j].Lines { + buf.WriteString(diff.Files[i].Sections[j].Lines[k].Content) + buf.WriteString("\n") + } + } + + charsetLabel, err := tool.DetectEncoding(buf.Bytes()) + if charsetLabel != "UTF-8" && err == nil { + encoding, _ := charset.Lookup(charsetLabel) + if encoding != nil { + d := encoding.NewDecoder() + for j := range diff.Files[i].Sections { + for k := range diff.Files[i].Sections[j].Lines { + if c, _, err := transform.String(d, diff.Files[i].Sections[j].Lines[k].Content); err == nil { + diff.Files[i].Sections[j].Lines[k].Content = c + } + } + } + } + } + } + + return diff +} + +func ParsePatch(maxLines, maxLineCharacteres, maxFiles int, reader io.Reader) (*Diff, error) { + done := make(chan error) + var gitDiff *git.Diff + go func() { + gitDiff = git.ParsePatch(done, maxLines, maxLineCharacteres, maxFiles, reader) + }() + + if err := <-done; err != nil { + return nil, fmt.Errorf("ParsePatch: %v", err) + } + return NewDiff(gitDiff), nil +} + +func GetDiffRange(repoPath, beforeCommitID, afterCommitID string, maxLines, maxLineCharacteres, maxFiles int) (*Diff, error) { + gitDiff, err := git.GetDiffRange(repoPath, beforeCommitID, afterCommitID, maxLines, maxLineCharacteres, maxFiles) + if err != nil { + return nil, fmt.Errorf("GetDiffRange: %v", err) + } + return NewDiff(gitDiff), nil +} + +func GetDiffCommit(repoPath, commitID string, maxLines, maxLineCharacteres, maxFiles int) (*Diff, error) { + gitDiff, err := git.GetDiffCommit(repoPath, commitID, maxLines, maxLineCharacteres, maxFiles) + if err != nil { + return nil, fmt.Errorf("GetDiffCommit: %v", err) + } + return NewDiff(gitDiff), nil +} diff --git a/internal/db/git_diff_test.go b/internal/db/git_diff_test.go new file mode 100644 index 00000000..d92afe9e --- /dev/null +++ b/internal/db/git_diff_test.go @@ -0,0 +1,41 @@ +// Copyright 2016 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package db + +import ( + "html/template" + "testing" + + "github.com/gogs/git-module" + dmp "github.com/sergi/go-diff/diffmatchpatch" +) + +func assertEqual(t *testing.T, s1 string, s2 template.HTML) { + if s1 != string(s2) { + t.Errorf("%s should be equal %s", s2, s1) + } +} + +func assertLineEqual(t *testing.T, d1 *git.DiffLine, d2 *git.DiffLine) { + if d1 != d2 { + t.Errorf("%v should be equal %v", d1, d2) + } +} + +func Test_diffToHTML(t *testing.T) { + assertEqual(t, "+foo <span class=\"added-code\">bar</span> biz", diffToHTML([]dmp.Diff{ + dmp.Diff{dmp.DiffEqual, "foo "}, + dmp.Diff{dmp.DiffInsert, "bar"}, + dmp.Diff{dmp.DiffDelete, " baz"}, + dmp.Diff{dmp.DiffEqual, " biz"}, + }, git.DIFF_LINE_ADD)) + + assertEqual(t, "-foo <span class=\"removed-code\">bar</span> biz", diffToHTML([]dmp.Diff{ + dmp.Diff{dmp.DiffEqual, "foo "}, + dmp.Diff{dmp.DiffDelete, "bar"}, + dmp.Diff{dmp.DiffInsert, " baz"}, + dmp.Diff{dmp.DiffEqual, " biz"}, + }, git.DIFF_LINE_DEL)) +} diff --git a/internal/db/issue.go b/internal/db/issue.go new file mode 100644 index 00000000..f176f876 --- /dev/null +++ b/internal/db/issue.go @@ -0,0 +1,1440 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package db + +import ( + "fmt" + "strings" + "time" + + "github.com/unknwon/com" + log "gopkg.in/clog.v1" + "xorm.io/xorm" + + api "github.com/gogs/go-gogs-client" + + "gogs.io/gogs/internal/db/errors" + "gogs.io/gogs/internal/setting" + "gogs.io/gogs/internal/tool" +) + +var ( + ErrMissingIssueNumber = errors.New("No issue number specified") +) + +// Issue represents an issue or pull request of repository. +type Issue struct { + ID int64 + RepoID int64 `xorm:"INDEX UNIQUE(repo_index)"` + Repo *Repository `xorm:"-" json:"-"` + Index int64 `xorm:"UNIQUE(repo_index)"` // Index in one repository. + PosterID int64 + Poster *User `xorm:"-" json:"-"` + Title string `xorm:"name"` + Content string `xorm:"TEXT"` + RenderedContent string `xorm:"-" json:"-"` + Labels []*Label `xorm:"-" json:"-"` + MilestoneID int64 + Milestone *Milestone `xorm:"-" json:"-"` + Priority int + AssigneeID int64 + Assignee *User `xorm:"-" json:"-"` + IsClosed bool + IsRead bool `xorm:"-" json:"-"` + IsPull bool // Indicates whether is a pull request or not. + PullRequest *PullRequest `xorm:"-" json:"-"` + NumComments int + + Deadline time.Time `xorm:"-" json:"-"` + DeadlineUnix int64 + Created time.Time `xorm:"-" json:"-"` + CreatedUnix int64 + Updated time.Time `xorm:"-" json:"-"` + UpdatedUnix int64 + + Attachments []*Attachment `xorm:"-" json:"-"` + Comments []*Comment `xorm:"-" json:"-"` +} + +func (issue *Issue) BeforeInsert() { + issue.CreatedUnix = time.Now().Unix() + issue.UpdatedUnix = issue.CreatedUnix +} + +func (issue *Issue) BeforeUpdate() { + issue.UpdatedUnix = time.Now().Unix() + issue.DeadlineUnix = issue.Deadline.Unix() +} + +func (issue *Issue) AfterSet(colName string, _ xorm.Cell) { + switch colName { + case "deadline_unix": + issue.Deadline = time.Unix(issue.DeadlineUnix, 0).Local() + case "created_unix": + issue.Created = time.Unix(issue.CreatedUnix, 0).Local() + case "updated_unix": + issue.Updated = time.Unix(issue.UpdatedUnix, 0).Local() + } +} + +func (issue *Issue) loadAttributes(e Engine) (err error) { + if issue.Repo == nil { + issue.Repo, err = getRepositoryByID(e, issue.RepoID) + if err != nil { + return fmt.Errorf("getRepositoryByID [%d]: %v", issue.RepoID, err) + } + } + + if issue.Poster == nil { + issue.Poster, err = getUserByID(e, issue.PosterID) + if err != nil { + if errors.IsUserNotExist(err) { + issue.PosterID = -1 + issue.Poster = NewGhostUser() + } else { + return fmt.Errorf("getUserByID.(Poster) [%d]: %v", issue.PosterID, err) + } + } + } + + if issue.Labels == nil { + issue.Labels, err = getLabelsByIssueID(e, issue.ID) + if err != nil { + return fmt.Errorf("getLabelsByIssueID [%d]: %v", issue.ID, err) + } + } + + if issue.Milestone == nil && issue.MilestoneID > 0 { + issue.Milestone, err = getMilestoneByRepoID(e, issue.RepoID, issue.MilestoneID) + if err != nil { + return fmt.Errorf("getMilestoneByRepoID [repo_id: %d, milestone_id: %d]: %v", issue.RepoID, issue.MilestoneID, err) + } + } + + if issue.Assignee == nil && issue.AssigneeID > 0 { + issue.Assignee, err = getUserByID(e, issue.AssigneeID) + if err != nil { + return fmt.Errorf("getUserByID.(assignee) [%d]: %v", issue.AssigneeID, err) + } + } + + if issue.IsPull && issue.PullRequest == nil { + // It is possible pull request is not yet created. + issue.PullRequest, err = getPullRequestByIssueID(e, issue.ID) + if err != nil && !IsErrPullRequestNotExist(err) { + return fmt.Errorf("getPullRequestByIssueID [%d]: %v", issue.ID, err) + } + } + + if issue.Attachments == nil { + issue.Attachments, err = getAttachmentsByIssueID(e, issue.ID) + if err != nil { + return fmt.Errorf("getAttachmentsByIssueID [%d]: %v", issue.ID, err) + } + } + + if issue.Comments == nil { + issue.Comments, err = getCommentsByIssueID(e, issue.ID) + if err != nil { + return fmt.Errorf("getCommentsByIssueID [%d]: %v", issue.ID, err) + } + } + + return nil +} + +func (issue *Issue) LoadAttributes() error { + return issue.loadAttributes(x) +} + +func (issue *Issue) HTMLURL() string { + var path string + if issue.IsPull { + path = "pulls" + } else { + path = "issues" + } + return fmt.Sprintf("%s/%s/%d", issue.Repo.HTMLURL(), path, issue.Index) +} + +// State returns string representation of issue status. +func (issue *Issue) State() api.StateType { + if issue.IsClosed { + return api.STATE_CLOSED + } + return api.STATE_OPEN +} + +// This method assumes some fields assigned with values: +// Required - Poster, Labels, +// Optional - Milestone, Assignee, PullRequest +func (issue *Issue) APIFormat() *api.Issue { + apiLabels := make([]*api.Label, len(issue.Labels)) + for i := range issue.Labels { + apiLabels[i] = issue.Labels[i].APIFormat() + } + + apiIssue := &api.Issue{ + ID: issue.ID, + Index: issue.Index, + Poster: issue.Poster.APIFormat(), + Title: issue.Title, + Body: issue.Content, + Labels: apiLabels, + State: issue.State(), + Comments: issue.NumComments, + Created: issue.Created, + Updated: issue.Updated, + } + + if issue.Milestone != nil { + apiIssue.Milestone = issue.Milestone.APIFormat() + } + if issue.Assignee != nil { + apiIssue.Assignee = issue.Assignee.APIFormat() + } + if issue.IsPull { + apiIssue.PullRequest = &api.PullRequestMeta{ + HasMerged: issue.PullRequest.HasMerged, + } + if issue.PullRequest.HasMerged { + apiIssue.PullRequest.Merged = &issue.PullRequest.Merged + } + } + + return apiIssue +} + +// HashTag returns unique hash tag for issue. +func (issue *Issue) HashTag() string { + return "issue-" + com.ToStr(issue.ID) +} + +// IsPoster returns true if given user by ID is the poster. +func (issue *Issue) IsPoster(uid int64) bool { + return issue.PosterID == uid +} + +func (issue *Issue) hasLabel(e Engine, labelID int64) bool { + return hasIssueLabel(e, issue.ID, labelID) +} + +// HasLabel returns true if issue has been labeled by given ID. +func (issue *Issue) HasLabel(labelID int64) bool { + return issue.hasLabel(x, labelID) +} + +func (issue *Issue) sendLabelUpdatedWebhook(doer *User) { + var err error + if issue.IsPull { + err = issue.PullRequest.LoadIssue() + if err != nil { + log.Error(2, "LoadIssue: %v", err) + return + } + err = PrepareWebhooks(issue.Repo, HOOK_EVENT_PULL_REQUEST, &api.PullRequestPayload{ + Action: api.HOOK_ISSUE_LABEL_UPDATED, + Index: issue.Index, + PullRequest: issue.PullRequest.APIFormat(), + Repository: issue.Repo.APIFormat(nil), + Sender: doer.APIFormat(), + }) + } else { + err = PrepareWebhooks(issue.Repo, HOOK_EVENT_ISSUES, &api.IssuesPayload{ + Action: api.HOOK_ISSUE_LABEL_UPDATED, + Index: issue.Index, + Issue: issue.APIFormat(), + Repository: issue.Repo.APIFormat(nil), + Sender: doer.APIFormat(), + }) + } + if err != nil { + log.Error(2, "PrepareWebhooks [is_pull: %v]: %v", issue.IsPull, err) + } +} + +func (issue *Issue) addLabel(e *xorm.Session, label *Label) error { + return newIssueLabel(e, issue, label) +} + +// AddLabel adds a new label to the issue. +func (issue *Issue) AddLabel(doer *User, label *Label) error { + if err := NewIssueLabel(issue, label); err != nil { + return err + } + + issue.sendLabelUpdatedWebhook(doer) + return nil +} + +func (issue *Issue) addLabels(e *xorm.Session, labels []*Label) error { + return newIssueLabels(e, issue, labels) +} + +// AddLabels adds a list of new labels to the issue. +func (issue *Issue) AddLabels(doer *User, labels []*Label) error { + if err := NewIssueLabels(issue, labels); err != nil { + return err + } + + issue.sendLabelUpdatedWebhook(doer) + return nil +} + +func (issue *Issue) getLabels(e Engine) (err error) { + if len(issue.Labels) > 0 { + return nil + } + + issue.Labels, err = getLabelsByIssueID(e, issue.ID) + if err != nil { + return fmt.Errorf("getLabelsByIssueID: %v", err) + } + return nil +} + +func (issue *Issue) removeLabel(e *xorm.Session, label *Label) error { + return deleteIssueLabel(e, issue, label) +} + +// RemoveLabel removes a label from issue by given ID. +func (issue *Issue) RemoveLabel(doer *User, label *Label) error { + if err := DeleteIssueLabel(issue, label); err != nil { + return err + } + + issue.sendLabelUpdatedWebhook(doer) + return nil +} + +func (issue *Issue) clearLabels(e *xorm.Session) (err error) { + if err = issue.getLabels(e); err != nil { + return fmt.Errorf("getLabels: %v", err) + } + + // NOTE: issue.removeLabel slices issue.Labels, so we need to create another slice to be unaffected. + labels := make([]*Label, len(issue.Labels)) + for i := range issue.Labels { + labels[i] = issue.Labels[i] + } + for i := range labels { + if err = issue.removeLabel(e, labels[i]); err != nil { + return fmt.Errorf("removeLabel: %v", err) + } + } + + return nil +} + +func (issue *Issue) ClearLabels(doer *User) (err error) { + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return err + } + + if err = issue.clearLabels(sess); err != nil { + return err + } + + if err = sess.Commit(); err != nil { + return fmt.Errorf("Commit: %v", err) + } + + if issue.IsPull { + err = issue.PullRequest.LoadIssue() + if err != nil { + log.Error(2, "LoadIssue: %v", err) + return + } + err = PrepareWebhooks(issue.Repo, HOOK_EVENT_PULL_REQUEST, &api.PullRequestPayload{ + Action: api.HOOK_ISSUE_LABEL_CLEARED, + Index: issue.Index, + PullRequest: issue.PullRequest.APIFormat(), + Repository: issue.Repo.APIFormat(nil), + Sender: doer.APIFormat(), + }) + } else { + err = PrepareWebhooks(issue.Repo, HOOK_EVENT_ISSUES, &api.IssuesPayload{ + Action: api.HOOK_ISSUE_LABEL_CLEARED, + Index: issue.Index, + Issue: issue.APIFormat(), + Repository: issue.Repo.APIFormat(nil), + Sender: doer.APIFormat(), + }) + } + if err != nil { + log.Error(2, "PrepareWebhooks [is_pull: %v]: %v", issue.IsPull, err) + } + + return nil +} + +// ReplaceLabels removes all current labels and add new labels to the issue. +func (issue *Issue) ReplaceLabels(labels []*Label) (err error) { + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return err + } + + if err = issue.clearLabels(sess); err != nil { + return fmt.Errorf("clearLabels: %v", err) + } else if err = issue.addLabels(sess, labels); err != nil { + return fmt.Errorf("addLabels: %v", err) + } + + return sess.Commit() +} + +func (issue *Issue) GetAssignee() (err error) { + if issue.AssigneeID == 0 || issue.Assignee != nil { + return nil + } + + issue.Assignee, err = GetUserByID(issue.AssigneeID) + if errors.IsUserNotExist(err) { + return nil + } + return err +} + +// ReadBy sets issue to be read by given user. +func (issue *Issue) ReadBy(uid int64) error { + return UpdateIssueUserByRead(uid, issue.ID) +} + +func updateIssueCols(e Engine, issue *Issue, cols ...string) error { + _, err := e.ID(issue.ID).Cols(cols...).Update(issue) + return err +} + +// UpdateIssueCols only updates values of specific columns for given issue. +func UpdateIssueCols(issue *Issue, cols ...string) error { + return updateIssueCols(x, issue, cols...) +} + +func (issue *Issue) changeStatus(e *xorm.Session, doer *User, repo *Repository, isClosed bool) (err error) { + // Nothing should be performed if current status is same as target status + if issue.IsClosed == isClosed { + return nil + } + issue.IsClosed = isClosed + + if err = updateIssueCols(e, issue, "is_closed"); err != nil { + return err + } else if err = updateIssueUsersByStatus(e, issue.ID, isClosed); err != nil { + return err + } + + // Update issue count of labels + if err = issue.getLabels(e); err != nil { + return err + } + for idx := range issue.Labels { + if issue.IsClosed { + issue.Labels[idx].NumClosedIssues++ + } else { + issue.Labels[idx].NumClosedIssues-- + } + if err = updateLabel(e, issue.Labels[idx]); err != nil { + return err + } + } + + // Update issue count of milestone + if err = changeMilestoneIssueStats(e, issue); err != nil { + return err + } + + // New action comment + if _, err = createStatusComment(e, doer, repo, issue); err != nil { + return err + } + + return nil +} + +// ChangeStatus changes issue status to open or closed. +func (issue *Issue) ChangeStatus(doer *User, repo *Repository, isClosed bool) (err error) { + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return err + } + + if err = issue.changeStatus(sess, doer, repo, isClosed); err != nil { + return err + } + + if err = sess.Commit(); err != nil { + return fmt.Errorf("Commit: %v", err) + } + + if issue.IsPull { + // Merge pull request calls issue.changeStatus so we need to handle separately. + issue.PullRequest.Issue = issue + apiPullRequest := &api.PullRequestPayload{ + Index: issue.Index, + PullRequest: issue.PullRequest.APIFormat(), + Repository: repo.APIFormat(nil), + Sender: doer.APIFormat(), + } + if isClosed { + apiPullRequest.Action = api.HOOK_ISSUE_CLOSED + } else { + apiPullRequest.Action = api.HOOK_ISSUE_REOPENED + } + err = PrepareWebhooks(repo, HOOK_EVENT_PULL_REQUEST, apiPullRequest) + } else { + apiIssues := &api.IssuesPayload{ + Index: issue.Index, + Issue: issue.APIFormat(), + Repository: repo.APIFormat(nil), + Sender: doer.APIFormat(), + } + if isClosed { + apiIssues.Action = api.HOOK_ISSUE_CLOSED + } else { + apiIssues.Action = api.HOOK_ISSUE_REOPENED + } + err = PrepareWebhooks(repo, HOOK_EVENT_ISSUES, apiIssues) + } + if err != nil { + log.Error(2, "PrepareWebhooks [is_pull: %v, is_closed: %v]: %v", issue.IsPull, isClosed, err) + } + + return nil +} + +func (issue *Issue) ChangeTitle(doer *User, title string) (err error) { + oldTitle := issue.Title + issue.Title = title + if err = UpdateIssueCols(issue, "name"); err != nil { + return fmt.Errorf("UpdateIssueCols: %v", err) + } + + if issue.IsPull { + issue.PullRequest.Issue = issue + err = PrepareWebhooks(issue.Repo, HOOK_EVENT_PULL_REQUEST, &api.PullRequestPayload{ + Action: api.HOOK_ISSUE_EDITED, + Index: issue.Index, + PullRequest: issue.PullRequest.APIFormat(), + Changes: &api.ChangesPayload{ + Title: &api.ChangesFromPayload{ + From: oldTitle, + }, + }, + Repository: issue.Repo.APIFormat(nil), + Sender: doer.APIFormat(), + }) + } else { + err = PrepareWebhooks(issue.Repo, HOOK_EVENT_ISSUES, &api.IssuesPayload{ + Action: api.HOOK_ISSUE_EDITED, + Index: issue.Index, + Issue: issue.APIFormat(), + Changes: &api.ChangesPayload{ + Title: &api.ChangesFromPayload{ + From: oldTitle, + }, + }, + Repository: issue.Repo.APIFormat(nil), + Sender: doer.APIFormat(), + }) + } + if err != nil { + log.Error(2, "PrepareWebhooks [is_pull: %v]: %v", issue.IsPull, err) + } + + return nil +} + +func (issue *Issue) ChangeContent(doer *User, content string) (err error) { + oldContent := issue.Content + issue.Content = content + if err = UpdateIssueCols(issue, "content"); err != nil { + return fmt.Errorf("UpdateIssueCols: %v", err) + } + + if issue.IsPull { + issue.PullRequest.Issue = issue + err = PrepareWebhooks(issue.Repo, HOOK_EVENT_PULL_REQUEST, &api.PullRequestPayload{ + Action: api.HOOK_ISSUE_EDITED, + Index: issue.Index, + PullRequest: issue.PullRequest.APIFormat(), + Changes: &api.ChangesPayload{ + Body: &api.ChangesFromPayload{ + From: oldContent, + }, + }, + Repository: issue.Repo.APIFormat(nil), + Sender: doer.APIFormat(), + }) + } else { + err = PrepareWebhooks(issue.Repo, HOOK_EVENT_ISSUES, &api.IssuesPayload{ + Action: api.HOOK_ISSUE_EDITED, + Index: issue.Index, + Issue: issue.APIFormat(), + Changes: &api.ChangesPayload{ + Body: &api.ChangesFromPayload{ + From: oldContent, + }, + }, + Repository: issue.Repo.APIFormat(nil), + Sender: doer.APIFormat(), + }) + } + if err != nil { + log.Error(2, "PrepareWebhooks [is_pull: %v]: %v", issue.IsPull, err) + } + + return nil +} + +func (issue *Issue) ChangeAssignee(doer *User, assigneeID int64) (err error) { + issue.AssigneeID = assigneeID + if err = UpdateIssueUserByAssignee(issue); err != nil { + return fmt.Errorf("UpdateIssueUserByAssignee: %v", err) + } + + issue.Assignee, err = GetUserByID(issue.AssigneeID) + if err != nil && !errors.IsUserNotExist(err) { + log.Error(4, "GetUserByID [assignee_id: %v]: %v", issue.AssigneeID, err) + return nil + } + + // Error not nil here means user does not exist, which is remove assignee. + isRemoveAssignee := err != nil + if issue.IsPull { + issue.PullRequest.Issue = issue + apiPullRequest := &api.PullRequestPayload{ + Index: issue.Index, + PullRequest: issue.PullRequest.APIFormat(), + Repository: issue.Repo.APIFormat(nil), + Sender: doer.APIFormat(), + } + if isRemoveAssignee { + apiPullRequest.Action = api.HOOK_ISSUE_UNASSIGNED + } else { + apiPullRequest.Action = api.HOOK_ISSUE_ASSIGNED + } + err = PrepareWebhooks(issue.Repo, HOOK_EVENT_PULL_REQUEST, apiPullRequest) + } else { + apiIssues := &api.IssuesPayload{ + Index: issue.Index, + Issue: issue.APIFormat(), + Repository: issue.Repo.APIFormat(nil), + Sender: doer.APIFormat(), + } + if isRemoveAssignee { + apiIssues.Action = api.HOOK_ISSUE_UNASSIGNED + } else { + apiIssues.Action = api.HOOK_ISSUE_ASSIGNED + } + err = PrepareWebhooks(issue.Repo, HOOK_EVENT_ISSUES, apiIssues) + } + if err != nil { + log.Error(4, "PrepareWebhooks [is_pull: %v, remove_assignee: %v]: %v", issue.IsPull, isRemoveAssignee, err) + } + + return nil +} + +type NewIssueOptions struct { + Repo *Repository + Issue *Issue + LableIDs []int64 + Attachments []string // In UUID format. + IsPull bool +} + +func newIssue(e *xorm.Session, opts NewIssueOptions) (err error) { + opts.Issue.Title = strings.TrimSpace(opts.Issue.Title) + opts.Issue.Index = opts.Repo.NextIssueIndex() + + if opts.Issue.MilestoneID > 0 { + milestone, err := getMilestoneByRepoID(e, opts.Issue.RepoID, opts.Issue.MilestoneID) + if err != nil && !IsErrMilestoneNotExist(err) { + return fmt.Errorf("getMilestoneByID: %v", err) + } + + // Assume milestone is invalid and drop silently. + opts.Issue.MilestoneID = 0 + if milestone != nil { + opts.Issue.MilestoneID = milestone.ID + opts.Issue.Milestone = milestone + if err = changeMilestoneAssign(e, opts.Issue, -1); err != nil { + return err + } + } + } + + if opts.Issue.AssigneeID > 0 { + assignee, err := getUserByID(e, opts.Issue.AssigneeID) + if err != nil && !errors.IsUserNotExist(err) { + return fmt.Errorf("getUserByID: %v", err) + } + + // Assume assignee is invalid and drop silently. + opts.Issue.AssigneeID = 0 + if assignee != nil { + valid, err := hasAccess(e, assignee.ID, opts.Repo, ACCESS_MODE_READ) + if err != nil { + return fmt.Errorf("hasAccess [user_id: %d, repo_id: %d]: %v", assignee.ID, opts.Repo.ID, err) + } + if valid { + opts.Issue.AssigneeID = assignee.ID + opts.Issue.Assignee = assignee + } + } + } + + // Milestone and assignee validation should happen before insert actual object. + if _, err = e.Insert(opts.Issue); err != nil { + return err + } + + if opts.IsPull { + _, err = e.Exec("UPDATE `repository` SET num_pulls = num_pulls + 1 WHERE id = ?", opts.Issue.RepoID) + } else { + _, err = e.Exec("UPDATE `repository` SET num_issues = num_issues + 1 WHERE id = ?", opts.Issue.RepoID) + } + if err != nil { + return err + } + + if len(opts.LableIDs) > 0 { + // During the session, SQLite3 driver cannot handle retrieve objects after update something. + // So we have to get all needed labels first. + labels := make([]*Label, 0, len(opts.LableIDs)) + if err = e.In("id", opts.LableIDs).Find(&labels); err != nil { + return fmt.Errorf("find all labels [label_ids: %v]: %v", opts.LableIDs, err) + } + + for _, label := range labels { + // Silently drop invalid labels. + if label.RepoID != opts.Repo.ID { + continue + } + + if err = opts.Issue.addLabel(e, label); err != nil { + return fmt.Errorf("addLabel [id: %d]: %v", label.ID, err) + } + } + } + + if err = newIssueUsers(e, opts.Repo, opts.Issue); err != nil { + return err + } + + if len(opts.Attachments) > 0 { + attachments, err := getAttachmentsByUUIDs(e, opts.Attachments) + if err != nil { + return fmt.Errorf("getAttachmentsByUUIDs [uuids: %v]: %v", opts.Attachments, err) + } + + for i := 0; i < len(attachments); i++ { + attachments[i].IssueID = opts.Issue.ID + if _, err = e.Id(attachments[i].ID).Update(attachments[i]); err != nil { + return fmt.Errorf("update attachment [id: %d]: %v", attachments[i].ID, err) + } + } + } + + return opts.Issue.loadAttributes(e) +} + +// NewIssue creates new issue with labels and attachments for repository. +func NewIssue(repo *Repository, issue *Issue, labelIDs []int64, uuids []string) (err error) { + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return err + } + + if err = newIssue(sess, NewIssueOptions{ + Repo: repo, + Issue: issue, + LableIDs: labelIDs, + Attachments: uuids, + }); err != nil { + return fmt.Errorf("newIssue: %v", err) + } + + if err = sess.Commit(); err != nil { + return fmt.Errorf("Commit: %v", err) + } + + if err = NotifyWatchers(&Action{ + ActUserID: issue.Poster.ID, + ActUserName: issue.Poster.Name, + OpType: ACTION_CREATE_ISSUE, + Content: fmt.Sprintf("%d|%s", issue.Index, issue.Title), + RepoID: repo.ID, + RepoUserName: repo.Owner.Name, + RepoName: repo.Name, + IsPrivate: repo.IsPrivate, + }); err != nil { + log.Error(2, "NotifyWatchers: %v", err) + } + if err = issue.MailParticipants(); err != nil { + log.Error(2, "MailParticipants: %v", err) + } + + if err = PrepareWebhooks(repo, HOOK_EVENT_ISSUES, &api.IssuesPayload{ + Action: api.HOOK_ISSUE_OPENED, + Index: issue.Index, + Issue: issue.APIFormat(), + Repository: repo.APIFormat(nil), + Sender: issue.Poster.APIFormat(), + }); err != nil { + log.Error(2, "PrepareWebhooks: %v", err) + } + + return nil +} + +// GetIssueByRef returns an Issue specified by a GFM reference. +// See https://help.github.com/articles/writing-on-github#references for more information on the syntax. +func GetIssueByRef(ref string) (*Issue, error) { + n := strings.IndexByte(ref, byte('#')) + if n == -1 { + return nil, errors.InvalidIssueReference{ref} + } + + index := com.StrTo(ref[n+1:]).MustInt64() + if index == 0 { + return nil, errors.IssueNotExist{} + } + + repo, err := GetRepositoryByRef(ref[:n]) + if err != nil { + return nil, err + } + + issue, err := GetIssueByIndex(repo.ID, index) + if err != nil { + return nil, err + } + + return issue, issue.LoadAttributes() +} + +// GetIssueByIndex returns raw issue without loading attributes by index in a repository. +func GetRawIssueByIndex(repoID, index int64) (*Issue, error) { + issue := &Issue{ + RepoID: repoID, + Index: index, + } + has, err := x.Get(issue) + if err != nil { + return nil, err + } else if !has { + return nil, errors.IssueNotExist{0, repoID, index} + } + return issue, nil +} + +// GetIssueByIndex returns issue by index in a repository. +func GetIssueByIndex(repoID, index int64) (*Issue, error) { + issue, err := GetRawIssueByIndex(repoID, index) + if err != nil { + return nil, err + } + return issue, issue.LoadAttributes() +} + +func getRawIssueByID(e Engine, id int64) (*Issue, error) { + issue := new(Issue) + has, err := e.ID(id).Get(issue) + if err != nil { + return nil, err + } else if !has { + return nil, errors.IssueNotExist{id, 0, 0} + } + return issue, nil +} + +func getIssueByID(e Engine, id int64) (*Issue, error) { + issue, err := getRawIssueByID(e, id) + if err != nil { + return nil, err + } + return issue, issue.loadAttributes(e) +} + +// GetIssueByID returns an issue by given ID. +func GetIssueByID(id int64) (*Issue, error) { + return getIssueByID(x, id) +} + +type IssuesOptions struct { + UserID int64 + AssigneeID int64 + RepoID int64 + PosterID int64 + MilestoneID int64 + RepoIDs []int64 + Page int + IsClosed bool + IsMention bool + IsPull bool + Labels string + SortType string +} + +// buildIssuesQuery returns nil if it foresees there won't be any value returned. +func buildIssuesQuery(opts *IssuesOptions) *xorm.Session { + sess := x.NewSession() + + if opts.Page <= 0 { + opts.Page = 1 + } + + if opts.RepoID > 0 { + sess.Where("issue.repo_id=?", opts.RepoID).And("issue.is_closed=?", opts.IsClosed) + } else if opts.RepoIDs != nil { + // In case repository IDs are provided but actually no repository has issue. + if len(opts.RepoIDs) == 0 { + return nil + } + sess.In("issue.repo_id", opts.RepoIDs).And("issue.is_closed=?", opts.IsClosed) + } else { + sess.Where("issue.is_closed=?", opts.IsClosed) + } + + if opts.AssigneeID > 0 { + sess.And("issue.assignee_id=?", opts.AssigneeID) + } else if opts.PosterID > 0 { + sess.And("issue.poster_id=?", opts.PosterID) + } + + if opts.MilestoneID > 0 { + sess.And("issue.milestone_id=?", opts.MilestoneID) + } + + sess.And("issue.is_pull=?", opts.IsPull) + + switch opts.SortType { + case "oldest": + sess.Asc("issue.created_unix") + case "recentupdate": + sess.Desc("issue.updated_unix") + case "leastupdate": + sess.Asc("issue.updated_unix") + case "mostcomment": + sess.Desc("issue.num_comments") + case "leastcomment": + sess.Asc("issue.num_comments") + case "priority": + sess.Desc("issue.priority") + default: + sess.Desc("issue.created_unix") + } + + if len(opts.Labels) > 0 && opts.Labels != "0" { + labelIDs := strings.Split(opts.Labels, ",") + if len(labelIDs) > 0 { + sess.Join("INNER", "issue_label", "issue.id = issue_label.issue_id").In("issue_label.label_id", labelIDs) + } + } + + if opts.IsMention { + sess.Join("INNER", "issue_user", "issue.id = issue_user.issue_id").And("issue_user.is_mentioned = ?", true) + + if opts.UserID > 0 { + sess.And("issue_user.uid = ?", opts.UserID) + } + } + + return sess +} + +// IssuesCount returns the number of issues by given conditions. +func IssuesCount(opts *IssuesOptions) (int64, error) { + sess := buildIssuesQuery(opts) + if sess == nil { + return 0, nil + } + + return sess.Count(&Issue{}) +} + +// Issues returns a list of issues by given conditions. +func Issues(opts *IssuesOptions) ([]*Issue, error) { + sess := buildIssuesQuery(opts) + if sess == nil { + return make([]*Issue, 0), nil + } + + sess.Limit(setting.UI.IssuePagingNum, (opts.Page-1)*setting.UI.IssuePagingNum) + + issues := make([]*Issue, 0, setting.UI.IssuePagingNum) + if err := sess.Find(&issues); err != nil { + return nil, fmt.Errorf("Find: %v", err) + } + + // FIXME: use IssueList to improve performance. + for i := range issues { + if err := issues[i].LoadAttributes(); err != nil { + return nil, fmt.Errorf("LoadAttributes [%d]: %v", issues[i].ID, err) + } + } + + return issues, nil +} + +// GetParticipantsByIssueID returns all users who are participated in comments of an issue. +func GetParticipantsByIssueID(issueID int64) ([]*User, error) { + userIDs := make([]int64, 0, 5) + if err := x.Table("comment").Cols("poster_id"). + Where("issue_id = ?", issueID). + Distinct("poster_id"). + Find(&userIDs); err != nil { + return nil, fmt.Errorf("get poster IDs: %v", err) + } + if len(userIDs) == 0 { + return nil, nil + } + + users := make([]*User, 0, len(userIDs)) + return users, x.In("id", userIDs).Find(&users) +} + +// .___ ____ ___ +// | | ______ ________ __ ____ | | \______ ___________ +// | |/ ___// ___/ | \_/ __ \| | / ___// __ \_ __ \ +// | |\___ \ \___ \| | /\ ___/| | /\___ \\ ___/| | \/ +// |___/____ >____ >____/ \___ >______//____ >\___ >__| +// \/ \/ \/ \/ \/ + +// IssueUser represents an issue-user relation. +type IssueUser struct { + ID int64 + UID int64 `xorm:"INDEX"` // User ID. + IssueID int64 + RepoID int64 `xorm:"INDEX"` + MilestoneID int64 + IsRead bool + IsAssigned bool + IsMentioned bool + IsPoster bool + IsClosed bool +} + +func newIssueUsers(e *xorm.Session, repo *Repository, issue *Issue) error { + assignees, err := repo.getAssignees(e) + if err != nil { + return fmt.Errorf("getAssignees: %v", err) + } + + // Poster can be anyone, append later if not one of assignees. + isPosterAssignee := false + + // Leave a seat for poster itself to append later, but if poster is one of assignee + // and just waste 1 unit is cheaper than re-allocate memory once. + issueUsers := make([]*IssueUser, 0, len(assignees)+1) + for _, assignee := range assignees { + isPoster := assignee.ID == issue.PosterID + issueUsers = append(issueUsers, &IssueUser{ + IssueID: issue.ID, + RepoID: repo.ID, + UID: assignee.ID, + IsPoster: isPoster, + IsAssigned: assignee.ID == issue.AssigneeID, + }) + if !isPosterAssignee && isPoster { + isPosterAssignee = true + } + } + if !isPosterAssignee { + issueUsers = append(issueUsers, &IssueUser{ + IssueID: issue.ID, + RepoID: repo.ID, + UID: issue.PosterID, + IsPoster: true, + }) + } + + if _, err = e.Insert(issueUsers); err != nil { + return err + } + return nil +} + +// NewIssueUsers adds new issue-user relations for new issue of repository. +func NewIssueUsers(repo *Repository, issue *Issue) (err error) { + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return err + } + + if err = newIssueUsers(sess, repo, issue); err != nil { + return err + } + + return sess.Commit() +} + +// PairsContains returns true when pairs list contains given issue. +func PairsContains(ius []*IssueUser, issueId, uid int64) int { + for i := range ius { + if ius[i].IssueID == issueId && + ius[i].UID == uid { + return i + } + } + return -1 +} + +// GetIssueUsers returns issue-user pairs by given repository and user. +func GetIssueUsers(rid, uid int64, isClosed bool) ([]*IssueUser, error) { + ius := make([]*IssueUser, 0, 10) + err := x.Where("is_closed=?", isClosed).Find(&ius, &IssueUser{RepoID: rid, UID: uid}) + return ius, err +} + +// GetIssueUserPairsByRepoIds returns issue-user pairs by given repository IDs. +func GetIssueUserPairsByRepoIds(rids []int64, isClosed bool, page int) ([]*IssueUser, error) { + if len(rids) == 0 { + return []*IssueUser{}, nil + } + + ius := make([]*IssueUser, 0, 10) + sess := x.Limit(20, (page-1)*20).Where("is_closed=?", isClosed).In("repo_id", rids) + err := sess.Find(&ius) + return ius, err +} + +// GetIssueUserPairsByMode returns issue-user pairs by given repository and user. +func GetIssueUserPairsByMode(userID, repoID int64, filterMode FilterMode, isClosed bool, page int) ([]*IssueUser, error) { + ius := make([]*IssueUser, 0, 10) + sess := x.Limit(20, (page-1)*20).Where("uid=?", userID).And("is_closed=?", isClosed) + if repoID > 0 { + sess.And("repo_id=?", repoID) + } + + switch filterMode { + case FILTER_MODE_ASSIGN: + sess.And("is_assigned=?", true) + case FILTER_MODE_CREATE: + sess.And("is_poster=?", true) + default: + return ius, nil + } + err := sess.Find(&ius) + return ius, err +} + +// updateIssueMentions extracts mentioned people from content and +// updates issue-user relations for them. +func updateIssueMentions(e Engine, issueID int64, mentions []string) error { + if len(mentions) == 0 { + return nil + } + + for i := range mentions { + mentions[i] = strings.ToLower(mentions[i]) + } + users := make([]*User, 0, len(mentions)) + + if err := e.In("lower_name", mentions).Asc("lower_name").Find(&users); err != nil { + return fmt.Errorf("find mentioned users: %v", err) + } + + ids := make([]int64, 0, len(mentions)) + for _, user := range users { + ids = append(ids, user.ID) + if !user.IsOrganization() || user.NumMembers == 0 { + continue + } + + memberIDs := make([]int64, 0, user.NumMembers) + orgUsers, err := getOrgUsersByOrgID(e, user.ID) + if err != nil { + return fmt.Errorf("getOrgUsersByOrgID [%d]: %v", user.ID, err) + } + + for _, orgUser := range orgUsers { + memberIDs = append(memberIDs, orgUser.ID) + } + + ids = append(ids, memberIDs...) + } + + if err := updateIssueUsersByMentions(e, issueID, ids); err != nil { + return fmt.Errorf("UpdateIssueUsersByMentions: %v", err) + } + + return nil +} + +// IssueStats represents issue statistic information. +type IssueStats struct { + OpenCount, ClosedCount int64 + YourReposCount int64 + AssignCount int64 + CreateCount int64 + MentionCount int64 +} + +type FilterMode string + +const ( + FILTER_MODE_YOUR_REPOS FilterMode = "your_repositories" + FILTER_MODE_ASSIGN FilterMode = "assigned" + FILTER_MODE_CREATE FilterMode = "created_by" + FILTER_MODE_MENTION FilterMode = "mentioned" +) + +func parseCountResult(results []map[string][]byte) int64 { + if len(results) == 0 { + return 0 + } + for _, result := range results[0] { + return com.StrTo(string(result)).MustInt64() + } + return 0 +} + +type IssueStatsOptions struct { + RepoID int64 + UserID int64 + Labels string + MilestoneID int64 + AssigneeID int64 + FilterMode FilterMode + IsPull bool +} + +// GetIssueStats returns issue statistic information by given conditions. +func GetIssueStats(opts *IssueStatsOptions) *IssueStats { + stats := &IssueStats{} + + countSession := func(opts *IssueStatsOptions) *xorm.Session { + sess := x.Where("issue.repo_id = ?", opts.RepoID).And("is_pull = ?", opts.IsPull) + + if len(opts.Labels) > 0 && opts.Labels != "0" { + labelIDs := tool.StringsToInt64s(strings.Split(opts.Labels, ",")) + if len(labelIDs) > 0 { + sess.Join("INNER", "issue_label", "issue.id = issue_id").In("label_id", labelIDs) + } + } + + if opts.MilestoneID > 0 { + sess.And("issue.milestone_id = ?", opts.MilestoneID) + } + + if opts.AssigneeID > 0 { + sess.And("assignee_id = ?", opts.AssigneeID) + } + + return sess + } + + switch opts.FilterMode { + case FILTER_MODE_YOUR_REPOS, FILTER_MODE_ASSIGN: + stats.OpenCount, _ = countSession(opts). + And("is_closed = ?", false). + Count(new(Issue)) + + stats.ClosedCount, _ = countSession(opts). + And("is_closed = ?", true). + Count(new(Issue)) + case FILTER_MODE_CREATE: + stats.OpenCount, _ = countSession(opts). + And("poster_id = ?", opts.UserID). + And("is_closed = ?", false). + Count(new(Issue)) + + stats.ClosedCount, _ = countSession(opts). + And("poster_id = ?", opts.UserID). + And("is_closed = ?", true). + Count(new(Issue)) + case FILTER_MODE_MENTION: + stats.OpenCount, _ = countSession(opts). + Join("INNER", "issue_user", "issue.id = issue_user.issue_id"). + And("issue_user.uid = ?", opts.UserID). + And("issue_user.is_mentioned = ?", true). + And("issue.is_closed = ?", false). + Count(new(Issue)) + + stats.ClosedCount, _ = countSession(opts). + Join("INNER", "issue_user", "issue.id = issue_user.issue_id"). + And("issue_user.uid = ?", opts.UserID). + And("issue_user.is_mentioned = ?", true). + And("issue.is_closed = ?", true). + Count(new(Issue)) + } + return stats +} + +// GetUserIssueStats returns issue statistic information for dashboard by given conditions. +func GetUserIssueStats(repoID, userID int64, repoIDs []int64, filterMode FilterMode, isPull bool) *IssueStats { + stats := &IssueStats{} + hasAnyRepo := repoID > 0 || len(repoIDs) > 0 + countSession := func(isClosed, isPull bool, repoID int64, repoIDs []int64) *xorm.Session { + sess := x.Where("issue.is_closed = ?", isClosed).And("issue.is_pull = ?", isPull) + + if repoID > 0 { + sess.And("repo_id = ?", repoID) + } else if len(repoIDs) > 0 { + sess.In("repo_id", repoIDs) + } + + return sess + } + + stats.AssignCount, _ = countSession(false, isPull, repoID, nil). + And("assignee_id = ?", userID). + Count(new(Issue)) + + stats.CreateCount, _ = countSession(false, isPull, repoID, nil). + And("poster_id = ?", userID). + Count(new(Issue)) + + if hasAnyRepo { + stats.YourReposCount, _ = countSession(false, isPull, repoID, repoIDs). + Count(new(Issue)) + } + + switch filterMode { + case FILTER_MODE_YOUR_REPOS: + if !hasAnyRepo { + break + } + + stats.OpenCount, _ = countSession(false, isPull, repoID, repoIDs). + Count(new(Issue)) + stats.ClosedCount, _ = countSession(true, isPull, repoID, repoIDs). + Count(new(Issue)) + case FILTER_MODE_ASSIGN: + stats.OpenCount, _ = countSession(false, isPull, repoID, nil). + And("assignee_id = ?", userID). + Count(new(Issue)) + stats.ClosedCount, _ = countSession(true, isPull, repoID, nil). + And("assignee_id = ?", userID). + Count(new(Issue)) + case FILTER_MODE_CREATE: + stats.OpenCount, _ = countSession(false, isPull, repoID, nil). + And("poster_id = ?", userID). + Count(new(Issue)) + stats.ClosedCount, _ = countSession(true, isPull, repoID, nil). + And("poster_id = ?", userID). + Count(new(Issue)) + } + + return stats +} + +// GetRepoIssueStats returns number of open and closed repository issues by given filter mode. +func GetRepoIssueStats(repoID, userID int64, filterMode FilterMode, isPull bool) (numOpen int64, numClosed int64) { + countSession := func(isClosed, isPull bool, repoID int64) *xorm.Session { + sess := x.Where("issue.repo_id = ?", isClosed). + And("is_pull = ?", isPull). + And("repo_id = ?", repoID) + + return sess + } + + openCountSession := countSession(false, isPull, repoID) + closedCountSession := countSession(true, isPull, repoID) + + switch filterMode { + case FILTER_MODE_ASSIGN: + openCountSession.And("assignee_id = ?", userID) + closedCountSession.And("assignee_id = ?", userID) + case FILTER_MODE_CREATE: + openCountSession.And("poster_id = ?", userID) + closedCountSession.And("poster_id = ?", userID) + } + + openResult, _ := openCountSession.Count(new(Issue)) + closedResult, _ := closedCountSession.Count(new(Issue)) + + return openResult, closedResult +} + +func updateIssue(e Engine, issue *Issue) error { + _, err := e.ID(issue.ID).AllCols().Update(issue) + return err +} + +// UpdateIssue updates all fields of given issue. +func UpdateIssue(issue *Issue) error { + return updateIssue(x, issue) +} + +func updateIssueUsersByStatus(e Engine, issueID int64, isClosed bool) error { + _, err := e.Exec("UPDATE `issue_user` SET is_closed=? WHERE issue_id=?", isClosed, issueID) + return err +} + +// UpdateIssueUsersByStatus updates issue-user relations by issue status. +func UpdateIssueUsersByStatus(issueID int64, isClosed bool) error { + return updateIssueUsersByStatus(x, issueID, isClosed) +} + +func updateIssueUserByAssignee(e *xorm.Session, issue *Issue) (err error) { + if _, err = e.Exec("UPDATE `issue_user` SET is_assigned = ? WHERE issue_id = ?", false, issue.ID); err != nil { + return err + } + + // Assignee ID equals to 0 means clear assignee. + if issue.AssigneeID > 0 { + if _, err = e.Exec("UPDATE `issue_user` SET is_assigned = ? WHERE uid = ? AND issue_id = ?", true, issue.AssigneeID, issue.ID); err != nil { + return err + } + } + + return updateIssue(e, issue) +} + +// UpdateIssueUserByAssignee updates issue-user relation for assignee. +func UpdateIssueUserByAssignee(issue *Issue) (err error) { + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return err + } + + if err = updateIssueUserByAssignee(sess, issue); err != nil { + return err + } + + return sess.Commit() +} + +// UpdateIssueUserByRead updates issue-user relation for reading. +func UpdateIssueUserByRead(uid, issueID int64) error { + _, err := x.Exec("UPDATE `issue_user` SET is_read=? WHERE uid=? AND issue_id=?", true, uid, issueID) + return err +} + +// updateIssueUsersByMentions updates issue-user pairs by mentioning. +func updateIssueUsersByMentions(e Engine, issueID int64, uids []int64) error { + for _, uid := range uids { + iu := &IssueUser{ + UID: uid, + IssueID: issueID, + } + has, err := e.Get(iu) + if err != nil { + return err + } + + iu.IsMentioned = true + if has { + _, err = e.ID(iu.ID).AllCols().Update(iu) + } else { + _, err = e.Insert(iu) + } + if err != nil { + return err + } + } + return nil +} diff --git a/internal/db/issue_label.go b/internal/db/issue_label.go new file mode 100644 index 00000000..ab875771 --- /dev/null +++ b/internal/db/issue_label.go @@ -0,0 +1,374 @@ +// Copyright 2016 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package db + +import ( + "fmt" + "html/template" + "regexp" + "strconv" + "strings" + + "xorm.io/xorm" + + api "github.com/gogs/go-gogs-client" + + "gogs.io/gogs/internal/tool" +) + +var labelColorPattern = regexp.MustCompile("#([a-fA-F0-9]{6})") + +// GetLabelTemplateFile loads the label template file by given name, +// then parses and returns a list of name-color pairs. +func GetLabelTemplateFile(name string) ([][2]string, error) { + data, err := getRepoInitFile("label", name) + if err != nil { + return nil, fmt.Errorf("getRepoInitFile: %v", err) + } + + lines := strings.Split(string(data), "\n") + list := make([][2]string, 0, len(lines)) + for i := 0; i < len(lines); i++ { + line := strings.TrimSpace(lines[i]) + if len(line) == 0 { + continue + } + + fields := strings.SplitN(line, " ", 2) + if len(fields) != 2 { + return nil, fmt.Errorf("line is malformed: %s", line) + } + + if !labelColorPattern.MatchString(fields[0]) { + return nil, fmt.Errorf("bad HTML color code in line: %s", line) + } + + fields[1] = strings.TrimSpace(fields[1]) + list = append(list, [2]string{fields[1], fields[0]}) + } + + return list, nil +} + +// Label represents a label of repository for issues. +type Label struct { + ID int64 + RepoID int64 `xorm:"INDEX"` + Name string + Color string `xorm:"VARCHAR(7)"` + NumIssues int + NumClosedIssues int + NumOpenIssues int `xorm:"-" json:"-"` + IsChecked bool `xorm:"-" json:"-"` +} + +func (label *Label) APIFormat() *api.Label { + return &api.Label{ + ID: label.ID, + Name: label.Name, + Color: strings.TrimLeft(label.Color, "#"), + } +} + +// CalOpenIssues calculates the open issues of label. +func (label *Label) CalOpenIssues() { + label.NumOpenIssues = label.NumIssues - label.NumClosedIssues +} + +// ForegroundColor calculates the text color for labels based +// on their background color. +func (l *Label) ForegroundColor() template.CSS { + if strings.HasPrefix(l.Color, "#") { + if color, err := strconv.ParseUint(l.Color[1:], 16, 64); err == nil { + r := float32(0xFF & (color >> 16)) + g := float32(0xFF & (color >> 8)) + b := float32(0xFF & color) + luminance := (0.2126*r + 0.7152*g + 0.0722*b) / 255 + + if luminance < 0.66 { + return template.CSS("#fff") + } + } + } + + // default to black + return template.CSS("#000") +} + +// NewLabels creates new label(s) for a repository. +func NewLabels(labels ...*Label) error { + _, err := x.Insert(labels) + return err +} + +// getLabelOfRepoByName returns a label by Name in given repository. +// If pass repoID as 0, then ORM will ignore limitation of repository +// and can return arbitrary label with any valid ID. +func getLabelOfRepoByName(e Engine, repoID int64, labelName string) (*Label, error) { + if len(labelName) <= 0 { + return nil, ErrLabelNotExist{0, repoID} + } + + l := &Label{ + Name: labelName, + RepoID: repoID, + } + has, err := x.Get(l) + if err != nil { + return nil, err + } else if !has { + return nil, ErrLabelNotExist{0, l.RepoID} + } + return l, nil +} + +// getLabelInRepoByID returns a label by ID in given repository. +// If pass repoID as 0, then ORM will ignore limitation of repository +// and can return arbitrary label with any valid ID. +func getLabelOfRepoByID(e Engine, repoID, labelID int64) (*Label, error) { + if labelID <= 0 { + return nil, ErrLabelNotExist{labelID, repoID} + } + + l := &Label{ + ID: labelID, + RepoID: repoID, + } + has, err := x.Get(l) + if err != nil { + return nil, err + } else if !has { + return nil, ErrLabelNotExist{l.ID, l.RepoID} + } + return l, nil +} + +// GetLabelByID returns a label by given ID. +func GetLabelByID(id int64) (*Label, error) { + return getLabelOfRepoByID(x, 0, id) +} + +// GetLabelOfRepoByID returns a label by ID in given repository. +func GetLabelOfRepoByID(repoID, labelID int64) (*Label, error) { + return getLabelOfRepoByID(x, repoID, labelID) +} + +// GetLabelOfRepoByName returns a label by name in given repository. +func GetLabelOfRepoByName(repoID int64, labelName string) (*Label, error) { + return getLabelOfRepoByName(x, repoID, labelName) +} + +// GetLabelsInRepoByIDs returns a list of labels by IDs in given repository, +// it silently ignores label IDs that are not belong to the repository. +func GetLabelsInRepoByIDs(repoID int64, labelIDs []int64) ([]*Label, error) { + labels := make([]*Label, 0, len(labelIDs)) + return labels, x.Where("repo_id = ?", repoID).In("id", tool.Int64sToStrings(labelIDs)).Asc("name").Find(&labels) +} + +// GetLabelsByRepoID returns all labels that belong to given repository by ID. +func GetLabelsByRepoID(repoID int64) ([]*Label, error) { + labels := make([]*Label, 0, 10) + return labels, x.Where("repo_id = ?", repoID).Asc("name").Find(&labels) +} + +func getLabelsByIssueID(e Engine, issueID int64) ([]*Label, error) { + issueLabels, err := getIssueLabels(e, issueID) + if err != nil { + return nil, fmt.Errorf("getIssueLabels: %v", err) + } else if len(issueLabels) == 0 { + return []*Label{}, nil + } + + labelIDs := make([]int64, len(issueLabels)) + for i := range issueLabels { + labelIDs[i] = issueLabels[i].LabelID + } + + labels := make([]*Label, 0, len(labelIDs)) + return labels, e.Where("id > 0").In("id", tool.Int64sToStrings(labelIDs)).Asc("name").Find(&labels) +} + +// GetLabelsByIssueID returns all labels that belong to given issue by ID. +func GetLabelsByIssueID(issueID int64) ([]*Label, error) { + return getLabelsByIssueID(x, issueID) +} + +func updateLabel(e Engine, l *Label) error { + _, err := e.ID(l.ID).AllCols().Update(l) + return err +} + +// UpdateLabel updates label information. +func UpdateLabel(l *Label) error { + return updateLabel(x, l) +} + +// DeleteLabel delete a label of given repository. +func DeleteLabel(repoID, labelID int64) error { + _, err := GetLabelOfRepoByID(repoID, labelID) + if err != nil { + if IsErrLabelNotExist(err) { + return nil + } + return err + } + + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return err + } + + if _, err = sess.ID(labelID).Delete(new(Label)); err != nil { + return err + } else if _, err = sess.Where("label_id = ?", labelID).Delete(new(IssueLabel)); err != nil { + return err + } + + return sess.Commit() +} + +// .___ .____ ___. .__ +// | | ______ ________ __ ____ | | _____ \_ |__ ____ | | +// | |/ ___// ___/ | \_/ __ \| | \__ \ | __ \_/ __ \| | +// | |\___ \ \___ \| | /\ ___/| |___ / __ \| \_\ \ ___/| |__ +// |___/____ >____ >____/ \___ >_______ (____ /___ /\___ >____/ +// \/ \/ \/ \/ \/ \/ \/ + +// IssueLabel represetns an issue-lable relation. +type IssueLabel struct { + ID int64 + IssueID int64 `xorm:"UNIQUE(s)"` + LabelID int64 `xorm:"UNIQUE(s)"` +} + +func hasIssueLabel(e Engine, issueID, labelID int64) bool { + has, _ := e.Where("issue_id = ? AND label_id = ?", issueID, labelID).Get(new(IssueLabel)) + return has +} + +// HasIssueLabel returns true if issue has been labeled. +func HasIssueLabel(issueID, labelID int64) bool { + return hasIssueLabel(x, issueID, labelID) +} + +func newIssueLabel(e *xorm.Session, issue *Issue, label *Label) (err error) { + if _, err = e.Insert(&IssueLabel{ + IssueID: issue.ID, + LabelID: label.ID, + }); err != nil { + return err + } + + label.NumIssues++ + if issue.IsClosed { + label.NumClosedIssues++ + } + + if err = updateLabel(e, label); err != nil { + return fmt.Errorf("updateLabel: %v", err) + } + + issue.Labels = append(issue.Labels, label) + return nil +} + +// NewIssueLabel creates a new issue-label relation. +func NewIssueLabel(issue *Issue, label *Label) (err error) { + if HasIssueLabel(issue.ID, label.ID) { + return nil + } + + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return err + } + + if err = newIssueLabel(sess, issue, label); err != nil { + return err + } + + return sess.Commit() +} + +func newIssueLabels(e *xorm.Session, issue *Issue, labels []*Label) (err error) { + for i := range labels { + if hasIssueLabel(e, issue.ID, labels[i].ID) { + continue + } + + if err = newIssueLabel(e, issue, labels[i]); err != nil { + return fmt.Errorf("newIssueLabel: %v", err) + } + } + + return nil +} + +// NewIssueLabels creates a list of issue-label relations. +func NewIssueLabels(issue *Issue, labels []*Label) (err error) { + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return err + } + + if err = newIssueLabels(sess, issue, labels); err != nil { + return err + } + + return sess.Commit() +} + +func getIssueLabels(e Engine, issueID int64) ([]*IssueLabel, error) { + issueLabels := make([]*IssueLabel, 0, 10) + return issueLabels, e.Where("issue_id=?", issueID).Asc("label_id").Find(&issueLabels) +} + +// GetIssueLabels returns all issue-label relations of given issue by ID. +func GetIssueLabels(issueID int64) ([]*IssueLabel, error) { + return getIssueLabels(x, issueID) +} + +func deleteIssueLabel(e *xorm.Session, issue *Issue, label *Label) (err error) { + if _, err = e.Delete(&IssueLabel{ + IssueID: issue.ID, + LabelID: label.ID, + }); err != nil { + return err + } + + label.NumIssues-- + if issue.IsClosed { + label.NumClosedIssues-- + } + if err = updateLabel(e, label); err != nil { + return fmt.Errorf("updateLabel: %v", err) + } + + for i := range issue.Labels { + if issue.Labels[i].ID == label.ID { + issue.Labels = append(issue.Labels[:i], issue.Labels[i+1:]...) + break + } + } + return nil +} + +// DeleteIssueLabel deletes issue-label relation. +func DeleteIssueLabel(issue *Issue, label *Label) (err error) { + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return err + } + + if err = deleteIssueLabel(sess, issue, label); err != nil { + return err + } + + return sess.Commit() +} diff --git a/internal/db/issue_mail.go b/internal/db/issue_mail.go new file mode 100644 index 00000000..b1f81cc8 --- /dev/null +++ b/internal/db/issue_mail.go @@ -0,0 +1,180 @@ +// Copyright 2016 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package db + +import ( + "fmt" + + "github.com/unknwon/com" + log "gopkg.in/clog.v1" + + "gogs.io/gogs/internal/mailer" + "gogs.io/gogs/internal/markup" + "gogs.io/gogs/internal/setting" +) + +func (issue *Issue) MailSubject() string { + return fmt.Sprintf("[%s] %s (#%d)", issue.Repo.Name, issue.Title, issue.Index) +} + +// mailerUser is a wrapper for satisfying mailer.User interface. +type mailerUser struct { + user *User +} + +func (this mailerUser) ID() int64 { + return this.user.ID +} + +func (this mailerUser) DisplayName() string { + return this.user.DisplayName() +} + +func (this mailerUser) Email() string { + return this.user.Email +} + +func (this mailerUser) GenerateActivateCode() string { + return this.user.GenerateActivateCode() +} + +func (this mailerUser) GenerateEmailActivateCode(email string) string { + return this.user.GenerateEmailActivateCode(email) +} + +func NewMailerUser(u *User) mailer.User { + return mailerUser{u} +} + +// mailerRepo is a wrapper for satisfying mailer.Repository interface. +type mailerRepo struct { + repo *Repository +} + +func (this mailerRepo) FullName() string { + return this.repo.FullName() +} + +func (this mailerRepo) HTMLURL() string { + return this.repo.HTMLURL() +} + +func (this mailerRepo) ComposeMetas() map[string]string { + return this.repo.ComposeMetas() +} + +func NewMailerRepo(repo *Repository) mailer.Repository { + return mailerRepo{repo} +} + +// mailerIssue is a wrapper for satisfying mailer.Issue interface. +type mailerIssue struct { + issue *Issue +} + +func (this mailerIssue) MailSubject() string { + return this.issue.MailSubject() +} + +func (this mailerIssue) Content() string { + return this.issue.Content +} + +func (this mailerIssue) HTMLURL() string { + return this.issue.HTMLURL() +} + +func NewMailerIssue(issue *Issue) mailer.Issue { + return mailerIssue{issue} +} + +// mailIssueCommentToParticipants can be used for both new issue creation and comment. +// This functions sends two list of emails: +// 1. Repository watchers, users who participated in comments and the assignee. +// 2. Users who are not in 1. but get mentioned in current issue/comment. +func mailIssueCommentToParticipants(issue *Issue, doer *User, mentions []string) error { + if !setting.Service.EnableNotifyMail { + return nil + } + + watchers, err := GetWatchers(issue.RepoID) + if err != nil { + return fmt.Errorf("GetWatchers [repo_id: %d]: %v", issue.RepoID, err) + } + participants, err := GetParticipantsByIssueID(issue.ID) + if err != nil { + return fmt.Errorf("GetParticipantsByIssueID [issue_id: %d]: %v", issue.ID, err) + } + + // In case the issue poster is not watching the repository, + // even if we have duplicated in watchers, can be safely filtered out. + if issue.PosterID != doer.ID { + participants = append(participants, issue.Poster) + } + + tos := make([]string, 0, len(watchers)) // List of email addresses + names := make([]string, 0, len(watchers)) + for i := range watchers { + if watchers[i].UserID == doer.ID { + continue + } + + to, err := GetUserByID(watchers[i].UserID) + if err != nil { + return fmt.Errorf("GetUserByID [%d]: %v", watchers[i].UserID, err) + } + if to.IsOrganization() || !to.IsActive { + continue + } + + tos = append(tos, to.Email) + names = append(names, to.Name) + } + for i := range participants { + if participants[i].ID == doer.ID { + continue + } else if com.IsSliceContainsStr(names, participants[i].Name) { + continue + } + + tos = append(tos, participants[i].Email) + names = append(names, participants[i].Name) + } + if issue.Assignee != nil && issue.Assignee.ID != doer.ID { + if !com.IsSliceContainsStr(names, issue.Assignee.Name) { + tos = append(tos, issue.Assignee.Email) + names = append(names, issue.Assignee.Name) + } + } + mailer.SendIssueCommentMail(NewMailerIssue(issue), NewMailerRepo(issue.Repo), NewMailerUser(doer), tos) + + // Mail mentioned people and exclude watchers. + names = append(names, doer.Name) + tos = make([]string, 0, len(mentions)) // list of user names. + for i := range mentions { + if com.IsSliceContainsStr(names, mentions[i]) { + continue + } + + tos = append(tos, mentions[i]) + } + mailer.SendIssueMentionMail(NewMailerIssue(issue), NewMailerRepo(issue.Repo), NewMailerUser(doer), GetUserEmailsByNames(tos)) + return nil +} + +// MailParticipants sends new issue thread created emails to repository watchers +// and mentioned people. +func (issue *Issue) MailParticipants() (err error) { + mentions := markup.FindAllMentions(issue.Content) + if err = updateIssueMentions(x, issue.ID, mentions); err != nil { + return fmt.Errorf("UpdateIssueMentions [%d]: %v", issue.ID, err) + } + + if err = mailIssueCommentToParticipants(issue, issue.Poster, mentions); err != nil { + log.Error(2, "mailIssueCommentToParticipants: %v", err) + } + + return nil +} diff --git a/internal/db/login_source.go b/internal/db/login_source.go new file mode 100644 index 00000000..c9e5dcc9 --- /dev/null +++ b/internal/db/login_source.go @@ -0,0 +1,866 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +// FIXME: Put this file into its own package and separate into different files based on login sources. +package db + +import ( + "crypto/tls" + "fmt" + "net/smtp" + "net/textproto" + "os" + "path" + "strings" + "sync" + "time" + + "github.com/go-macaron/binding" + "github.com/json-iterator/go" + "github.com/unknwon/com" + log "gopkg.in/clog.v1" + "gopkg.in/ini.v1" + "xorm.io/core" + "xorm.io/xorm" + + "gogs.io/gogs/internal/auth/github" + "gogs.io/gogs/internal/auth/ldap" + "gogs.io/gogs/internal/auth/pam" + "gogs.io/gogs/internal/db/errors" + "gogs.io/gogs/internal/setting" +) + +type LoginType int + +// Note: new type must append to the end of list to maintain compatibility. +const ( + LOGIN_NOTYPE LoginType = iota + LOGIN_PLAIN // 1 + LOGIN_LDAP // 2 + LOGIN_SMTP // 3 + LOGIN_PAM // 4 + LOGIN_DLDAP // 5 + LOGIN_GITHUB // 6 +) + +var LoginNames = map[LoginType]string{ + LOGIN_LDAP: "LDAP (via BindDN)", + LOGIN_DLDAP: "LDAP (simple auth)", // Via direct bind + LOGIN_SMTP: "SMTP", + LOGIN_PAM: "PAM", + LOGIN_GITHUB: "GitHub", +} + +var SecurityProtocolNames = map[ldap.SecurityProtocol]string{ + ldap.SECURITY_PROTOCOL_UNENCRYPTED: "Unencrypted", + ldap.SECURITY_PROTOCOL_LDAPS: "LDAPS", + ldap.SECURITY_PROTOCOL_START_TLS: "StartTLS", +} + +// Ensure structs implemented interface. +var ( + _ core.Conversion = &LDAPConfig{} + _ core.Conversion = &SMTPConfig{} + _ core.Conversion = &PAMConfig{} + _ core.Conversion = &GitHubConfig{} +) + +type LDAPConfig struct { + *ldap.Source `ini:"config"` +} + +func (cfg *LDAPConfig) FromDB(bs []byte) error { + return jsoniter.Unmarshal(bs, &cfg) +} + +func (cfg *LDAPConfig) ToDB() ([]byte, error) { + return jsoniter.Marshal(cfg) +} + +func (cfg *LDAPConfig) SecurityProtocolName() string { + return SecurityProtocolNames[cfg.SecurityProtocol] +} + +type SMTPConfig struct { + Auth string + Host string + Port int + AllowedDomains string `xorm:"TEXT"` + TLS bool `ini:"tls"` + SkipVerify bool +} + +func (cfg *SMTPConfig) FromDB(bs []byte) error { + return jsoniter.Unmarshal(bs, cfg) +} + +func (cfg *SMTPConfig) ToDB() ([]byte, error) { + return jsoniter.Marshal(cfg) +} + +type PAMConfig struct { + ServiceName string // PAM service (e.g. system-auth) +} + +func (cfg *PAMConfig) FromDB(bs []byte) error { + return jsoniter.Unmarshal(bs, &cfg) +} + +func (cfg *PAMConfig) ToDB() ([]byte, error) { + return jsoniter.Marshal(cfg) +} + +type GitHubConfig struct { + APIEndpoint string // GitHub service (e.g. https://api.github.com/) +} + +func (cfg *GitHubConfig) FromDB(bs []byte) error { + return jsoniter.Unmarshal(bs, &cfg) +} + +func (cfg *GitHubConfig) ToDB() ([]byte, error) { + return jsoniter.Marshal(cfg) +} + +// AuthSourceFile contains information of an authentication source file. +type AuthSourceFile struct { + abspath string + file *ini.File +} + +// SetGeneral sets new value to the given key in the general (default) section. +func (f *AuthSourceFile) SetGeneral(name, value string) { + f.file.Section("").Key(name).SetValue(value) +} + +// SetConfig sets new values to the "config" section. +func (f *AuthSourceFile) SetConfig(cfg core.Conversion) error { + return f.file.Section("config").ReflectFrom(cfg) +} + +// Save writes updates into file system. +func (f *AuthSourceFile) Save() error { + return f.file.SaveTo(f.abspath) +} + +// LoginSource represents an external way for authorizing users. +type LoginSource struct { + ID int64 + Type LoginType + Name string `xorm:"UNIQUE"` + IsActived bool `xorm:"NOT NULL DEFAULT false"` + IsDefault bool `xorm:"DEFAULT false"` + Cfg core.Conversion `xorm:"TEXT"` + + Created time.Time `xorm:"-" json:"-"` + CreatedUnix int64 + Updated time.Time `xorm:"-" json:"-"` + UpdatedUnix int64 + + LocalFile *AuthSourceFile `xorm:"-" json:"-"` +} + +func (s *LoginSource) BeforeInsert() { + s.CreatedUnix = time.Now().Unix() + s.UpdatedUnix = s.CreatedUnix +} + +func (s *LoginSource) BeforeUpdate() { + s.UpdatedUnix = time.Now().Unix() +} + +// Cell2Int64 converts a xorm.Cell type to int64, +// and handles possible irregular cases. +func Cell2Int64(val xorm.Cell) int64 { + switch (*val).(type) { + case []uint8: + log.Trace("Cell2Int64 ([]uint8): %v", *val) + return com.StrTo(string((*val).([]uint8))).MustInt64() + } + return (*val).(int64) +} + +func (s *LoginSource) BeforeSet(colName string, val xorm.Cell) { + switch colName { + case "type": + switch LoginType(Cell2Int64(val)) { + case LOGIN_LDAP, LOGIN_DLDAP: + s.Cfg = new(LDAPConfig) + case LOGIN_SMTP: + s.Cfg = new(SMTPConfig) + case LOGIN_PAM: + s.Cfg = new(PAMConfig) + case LOGIN_GITHUB: + s.Cfg = new(GitHubConfig) + default: + panic("unrecognized login source type: " + com.ToStr(*val)) + } + } +} + +func (s *LoginSource) AfterSet(colName string, _ xorm.Cell) { + switch colName { + case "created_unix": + s.Created = time.Unix(s.CreatedUnix, 0).Local() + case "updated_unix": + s.Updated = time.Unix(s.UpdatedUnix, 0).Local() + } +} + +func (s *LoginSource) TypeName() string { + return LoginNames[s.Type] +} + +func (s *LoginSource) IsLDAP() bool { + return s.Type == LOGIN_LDAP +} + +func (s *LoginSource) IsDLDAP() bool { + return s.Type == LOGIN_DLDAP +} + +func (s *LoginSource) IsSMTP() bool { + return s.Type == LOGIN_SMTP +} + +func (s *LoginSource) IsPAM() bool { + return s.Type == LOGIN_PAM +} + +func (s *LoginSource) IsGitHub() bool { + return s.Type == LOGIN_GITHUB +} + +func (s *LoginSource) HasTLS() bool { + return ((s.IsLDAP() || s.IsDLDAP()) && + s.LDAP().SecurityProtocol > ldap.SECURITY_PROTOCOL_UNENCRYPTED) || + s.IsSMTP() +} + +func (s *LoginSource) UseTLS() bool { + switch s.Type { + case LOGIN_LDAP, LOGIN_DLDAP: + return s.LDAP().SecurityProtocol != ldap.SECURITY_PROTOCOL_UNENCRYPTED + case LOGIN_SMTP: + return s.SMTP().TLS + } + + return false +} + +func (s *LoginSource) SkipVerify() bool { + switch s.Type { + case LOGIN_LDAP, LOGIN_DLDAP: + return s.LDAP().SkipVerify + case LOGIN_SMTP: + return s.SMTP().SkipVerify + } + + return false +} + +func (s *LoginSource) LDAP() *LDAPConfig { + return s.Cfg.(*LDAPConfig) +} + +func (s *LoginSource) SMTP() *SMTPConfig { + return s.Cfg.(*SMTPConfig) +} + +func (s *LoginSource) PAM() *PAMConfig { + return s.Cfg.(*PAMConfig) +} + +func (s *LoginSource) GitHub() *GitHubConfig { + return s.Cfg.(*GitHubConfig) +} + +func CreateLoginSource(source *LoginSource) error { + has, err := x.Get(&LoginSource{Name: source.Name}) + if err != nil { + return err + } else if has { + return ErrLoginSourceAlreadyExist{source.Name} + } + + _, err = x.Insert(source) + if err != nil { + return err + } else if source.IsDefault { + return ResetNonDefaultLoginSources(source) + } + return nil +} + +// LoginSources returns all login sources defined. +func LoginSources() ([]*LoginSource, error) { + sources := make([]*LoginSource, 0, 2) + if err := x.Find(&sources); err != nil { + return nil, err + } + + return append(sources, localLoginSources.List()...), nil +} + +// ActivatedLoginSources returns login sources that are currently activated. +func ActivatedLoginSources() ([]*LoginSource, error) { + sources := make([]*LoginSource, 0, 2) + if err := x.Where("is_actived = ?", true).Find(&sources); err != nil { + return nil, fmt.Errorf("find activated login sources: %v", err) + } + return append(sources, localLoginSources.ActivatedList()...), nil +} + +// GetLoginSourceByID returns login source by given ID. +func GetLoginSourceByID(id int64) (*LoginSource, error) { + source := new(LoginSource) + has, err := x.Id(id).Get(source) + if err != nil { + return nil, err + } else if !has { + return localLoginSources.GetLoginSourceByID(id) + } + return source, nil +} + +// ResetNonDefaultLoginSources clean other default source flag +func ResetNonDefaultLoginSources(source *LoginSource) error { + // update changes to DB + if _, err := x.NotIn("id", []int64{source.ID}).Cols("is_default").Update(&LoginSource{IsDefault: false}); err != nil { + return err + } + // write changes to local authentications + for i := range localLoginSources.sources { + if localLoginSources.sources[i].LocalFile != nil && localLoginSources.sources[i].ID != source.ID { + localLoginSources.sources[i].LocalFile.SetGeneral("is_default", "false") + if err := localLoginSources.sources[i].LocalFile.SetConfig(source.Cfg); err != nil { + return fmt.Errorf("LocalFile.SetConfig: %v", err) + } else if err = localLoginSources.sources[i].LocalFile.Save(); err != nil { + return fmt.Errorf("LocalFile.Save: %v", err) + } + } + } + // flush memory so that web page can show the same behaviors + localLoginSources.UpdateLoginSource(source) + return nil +} + +// UpdateLoginSource updates information of login source to database or local file. +func UpdateLoginSource(source *LoginSource) error { + if source.LocalFile == nil { + if _, err := x.Id(source.ID).AllCols().Update(source); err != nil { + return err + } else { + return ResetNonDefaultLoginSources(source) + } + + } + + source.LocalFile.SetGeneral("name", source.Name) + source.LocalFile.SetGeneral("is_activated", com.ToStr(source.IsActived)) + source.LocalFile.SetGeneral("is_default", com.ToStr(source.IsDefault)) + if err := source.LocalFile.SetConfig(source.Cfg); err != nil { + return fmt.Errorf("LocalFile.SetConfig: %v", err) + } else if err = source.LocalFile.Save(); err != nil { + return fmt.Errorf("LocalFile.Save: %v", err) + } + return ResetNonDefaultLoginSources(source) +} + +func DeleteSource(source *LoginSource) error { + count, err := x.Count(&User{LoginSource: source.ID}) + if err != nil { + return err + } else if count > 0 { + return ErrLoginSourceInUse{source.ID} + } + _, err = x.Id(source.ID).Delete(new(LoginSource)) + return err +} + +// CountLoginSources returns total number of login sources. +func CountLoginSources() int64 { + count, _ := x.Count(new(LoginSource)) + return count + int64(localLoginSources.Len()) +} + +// LocalLoginSources contains authentication sources configured and loaded from local files. +// Calling its methods is thread-safe; otherwise, please maintain the mutex accordingly. +type LocalLoginSources struct { + sync.RWMutex + sources []*LoginSource +} + +func (s *LocalLoginSources) Len() int { + return len(s.sources) +} + +// List returns full clone of login sources. +func (s *LocalLoginSources) List() []*LoginSource { + s.RLock() + defer s.RUnlock() + + list := make([]*LoginSource, s.Len()) + for i := range s.sources { + list[i] = &LoginSource{} + *list[i] = *s.sources[i] + } + return list +} + +// ActivatedList returns clone of activated login sources. +func (s *LocalLoginSources) ActivatedList() []*LoginSource { + s.RLock() + defer s.RUnlock() + + list := make([]*LoginSource, 0, 2) + for i := range s.sources { + if !s.sources[i].IsActived { + continue + } + source := &LoginSource{} + *source = *s.sources[i] + list = append(list, source) + } + return list +} + +// GetLoginSourceByID returns a clone of login source by given ID. +func (s *LocalLoginSources) GetLoginSourceByID(id int64) (*LoginSource, error) { + s.RLock() + defer s.RUnlock() + + for i := range s.sources { + if s.sources[i].ID == id { + source := &LoginSource{} + *source = *s.sources[i] + return source, nil + } + } + + return nil, errors.LoginSourceNotExist{id} +} + +// UpdateLoginSource updates in-memory copy of the authentication source. +func (s *LocalLoginSources) UpdateLoginSource(source *LoginSource) { + s.Lock() + defer s.Unlock() + + source.Updated = time.Now() + for i := range s.sources { + if s.sources[i].ID == source.ID { + *s.sources[i] = *source + } else if source.IsDefault { + s.sources[i].IsDefault = false + } + } +} + +var localLoginSources = &LocalLoginSources{} + +// LoadAuthSources loads authentication sources from local files +// and converts them into login sources. +func LoadAuthSources() { + authdPath := path.Join(setting.CustomPath, "conf/auth.d") + if !com.IsDir(authdPath) { + return + } + + paths, err := com.GetFileListBySuffix(authdPath, ".conf") + if err != nil { + log.Fatal(2, "Failed to list authentication sources: %v", err) + } + + localLoginSources.sources = make([]*LoginSource, 0, len(paths)) + + for _, fpath := range paths { + authSource, err := ini.Load(fpath) + if err != nil { + log.Fatal(2, "Failed to load authentication source: %v", err) + } + authSource.NameMapper = ini.TitleUnderscore + + // Set general attributes + s := authSource.Section("") + loginSource := &LoginSource{ + ID: s.Key("id").MustInt64(), + Name: s.Key("name").String(), + IsActived: s.Key("is_activated").MustBool(), + IsDefault: s.Key("is_default").MustBool(), + LocalFile: &AuthSourceFile{ + abspath: fpath, + file: authSource, + }, + } + + fi, err := os.Stat(fpath) + if err != nil { + log.Fatal(2, "Failed to load authentication source: %v", err) + } + loginSource.Updated = fi.ModTime() + + // Parse authentication source file + authType := s.Key("type").String() + switch authType { + case "ldap_bind_dn": + loginSource.Type = LOGIN_LDAP + loginSource.Cfg = &LDAPConfig{} + case "ldap_simple_auth": + loginSource.Type = LOGIN_DLDAP + loginSource.Cfg = &LDAPConfig{} + case "smtp": + loginSource.Type = LOGIN_SMTP + loginSource.Cfg = &SMTPConfig{} + case "pam": + loginSource.Type = LOGIN_PAM + loginSource.Cfg = &PAMConfig{} + case "github": + loginSource.Type = LOGIN_GITHUB + loginSource.Cfg = &GitHubConfig{} + default: + log.Fatal(2, "Failed to load authentication source: unknown type '%s'", authType) + } + + if err = authSource.Section("config").MapTo(loginSource.Cfg); err != nil { + log.Fatal(2, "Failed to parse authentication source 'config': %v", err) + } + + localLoginSources.sources = append(localLoginSources.sources, loginSource) + } +} + +// .____ ________ _____ __________ +// | | \______ \ / _ \\______ \ +// | | | | \ / /_\ \| ___/ +// | |___ | ` \/ | \ | +// |_______ \/_______ /\____|__ /____| +// \/ \/ \/ + +func composeFullName(firstname, surname, username string) string { + switch { + case len(firstname) == 0 && len(surname) == 0: + return username + case len(firstname) == 0: + return surname + case len(surname) == 0: + return firstname + default: + return firstname + " " + surname + } +} + +// LoginViaLDAP queries if login/password is valid against the LDAP directory pool, +// and create a local user if success when enabled. +func LoginViaLDAP(user *User, login, password string, source *LoginSource, autoRegister bool) (*User, error) { + username, fn, sn, mail, isAdmin, succeed := source.Cfg.(*LDAPConfig).SearchEntry(login, password, source.Type == LOGIN_DLDAP) + if !succeed { + // User not in LDAP, do nothing + return nil, errors.UserNotExist{0, login} + } + + if !autoRegister { + return user, nil + } + + // Fallback. + if len(username) == 0 { + username = login + } + // Validate username make sure it satisfies requirement. + if binding.AlphaDashDotPattern.MatchString(username) { + return nil, fmt.Errorf("Invalid pattern for attribute 'username' [%s]: must be valid alpha or numeric or dash(-_) or dot characters", username) + } + + if len(mail) == 0 { + mail = fmt.Sprintf("%s@localhost", username) + } + + user = &User{ + LowerName: strings.ToLower(username), + Name: username, + FullName: composeFullName(fn, sn, username), + Email: mail, + LoginType: source.Type, + LoginSource: source.ID, + LoginName: login, + IsActive: true, + IsAdmin: isAdmin, + } + + ok, err := IsUserExist(0, user.Name) + if err != nil { + return user, err + } + + if ok { + return user, UpdateUser(user) + } + + return user, CreateUser(user) +} + +// _________ __________________________ +// / _____/ / \__ ___/\______ \ +// \_____ \ / \ / \| | | ___/ +// / \/ Y \ | | | +// /_______ /\____|__ /____| |____| +// \/ \/ + +type smtpLoginAuth struct { + username, password string +} + +func (auth *smtpLoginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) { + return "LOGIN", []byte(auth.username), nil +} + +func (auth *smtpLoginAuth) Next(fromServer []byte, more bool) ([]byte, error) { + if more { + switch string(fromServer) { + case "Username:": + return []byte(auth.username), nil + case "Password:": + return []byte(auth.password), nil + } + } + return nil, nil +} + +const ( + SMTP_PLAIN = "PLAIN" + SMTP_LOGIN = "LOGIN" +) + +var SMTPAuths = []string{SMTP_PLAIN, SMTP_LOGIN} + +func SMTPAuth(a smtp.Auth, cfg *SMTPConfig) error { + c, err := smtp.Dial(fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)) + if err != nil { + return err + } + defer c.Close() + + if err = c.Hello("gogs"); err != nil { + return err + } + + if cfg.TLS { + if ok, _ := c.Extension("STARTTLS"); ok { + if err = c.StartTLS(&tls.Config{ + InsecureSkipVerify: cfg.SkipVerify, + ServerName: cfg.Host, + }); err != nil { + return err + } + } else { + return errors.New("SMTP server unsupports TLS") + } + } + + if ok, _ := c.Extension("AUTH"); ok { + if err = c.Auth(a); err != nil { + return err + } + return nil + } + return errors.New("Unsupported SMTP authentication method") +} + +// LoginViaSMTP queries if login/password is valid against the SMTP, +// and create a local user if success when enabled. +func LoginViaSMTP(user *User, login, password string, sourceID int64, cfg *SMTPConfig, autoRegister bool) (*User, error) { + // Verify allowed domains. + if len(cfg.AllowedDomains) > 0 { + idx := strings.Index(login, "@") + if idx == -1 { + return nil, errors.UserNotExist{0, login} + } else if !com.IsSliceContainsStr(strings.Split(cfg.AllowedDomains, ","), login[idx+1:]) { + return nil, errors.UserNotExist{0, login} + } + } + + var auth smtp.Auth + if cfg.Auth == SMTP_PLAIN { + auth = smtp.PlainAuth("", login, password, cfg.Host) + } else if cfg.Auth == SMTP_LOGIN { + auth = &smtpLoginAuth{login, password} + } else { + return nil, errors.New("Unsupported SMTP authentication type") + } + + if err := SMTPAuth(auth, cfg); err != nil { + // Check standard error format first, + // then fallback to worse case. + tperr, ok := err.(*textproto.Error) + if (ok && tperr.Code == 535) || + strings.Contains(err.Error(), "Username and Password not accepted") { + return nil, errors.UserNotExist{0, login} + } + return nil, err + } + + if !autoRegister { + return user, nil + } + + username := login + idx := strings.Index(login, "@") + if idx > -1 { + username = login[:idx] + } + + user = &User{ + LowerName: strings.ToLower(username), + Name: strings.ToLower(username), + Email: login, + Passwd: password, + LoginType: LOGIN_SMTP, + LoginSource: sourceID, + LoginName: login, + IsActive: true, + } + return user, CreateUser(user) +} + +// __________ _____ _____ +// \______ \/ _ \ / \ +// | ___/ /_\ \ / \ / \ +// | | / | \/ Y \ +// |____| \____|__ /\____|__ / +// \/ \/ + +// LoginViaPAM queries if login/password is valid against the PAM, +// and create a local user if success when enabled. +func LoginViaPAM(user *User, login, password string, sourceID int64, cfg *PAMConfig, autoRegister bool) (*User, error) { + if err := pam.PAMAuth(cfg.ServiceName, login, password); err != nil { + if strings.Contains(err.Error(), "Authentication failure") { + return nil, errors.UserNotExist{0, login} + } + return nil, err + } + + if !autoRegister { + return user, nil + } + + user = &User{ + LowerName: strings.ToLower(login), + Name: login, + Email: login, + Passwd: password, + LoginType: LOGIN_PAM, + LoginSource: sourceID, + LoginName: login, + IsActive: true, + } + return user, CreateUser(user) +} + +//________.__ __ ___ ___ ___. +/// _____/|__|/ |_ / | \ __ _\_ |__ +/// \ ___| \ __\/ ~ \ | \ __ \ +//\ \_\ \ || | \ Y / | / \_\ \ +//\______ /__||__| \___|_ /|____/|___ / +//\/ \/ \/ + +func LoginViaGitHub(user *User, login, password string, sourceID int64, cfg *GitHubConfig, autoRegister bool) (*User, error) { + fullname, email, url, location, err := github.Authenticate(cfg.APIEndpoint, login, password) + if err != nil { + if strings.Contains(err.Error(), "401") { + return nil, errors.UserNotExist{0, login} + } + return nil, err + } + + if !autoRegister { + return user, nil + } + user = &User{ + LowerName: strings.ToLower(login), + Name: login, + FullName: fullname, + Email: email, + Website: url, + Passwd: password, + LoginType: LOGIN_GITHUB, + LoginSource: sourceID, + LoginName: login, + IsActive: true, + Location: location, + } + return user, CreateUser(user) +} + +func remoteUserLogin(user *User, login, password string, source *LoginSource, autoRegister bool) (*User, error) { + if !source.IsActived { + return nil, errors.LoginSourceNotActivated{source.ID} + } + + switch source.Type { + case LOGIN_LDAP, LOGIN_DLDAP: + return LoginViaLDAP(user, login, password, source, autoRegister) + case LOGIN_SMTP: + return LoginViaSMTP(user, login, password, source.ID, source.Cfg.(*SMTPConfig), autoRegister) + case LOGIN_PAM: + return LoginViaPAM(user, login, password, source.ID, source.Cfg.(*PAMConfig), autoRegister) + case LOGIN_GITHUB: + return LoginViaGitHub(user, login, password, source.ID, source.Cfg.(*GitHubConfig), autoRegister) + } + + return nil, errors.InvalidLoginSourceType{source.Type} +} + +// UserLogin validates user name and password via given login source ID. +// If the loginSourceID is negative, it will abort login process if user is not found. +func UserLogin(username, password string, loginSourceID int64) (*User, error) { + var user *User + if strings.Contains(username, "@") { + user = &User{Email: strings.ToLower(username)} + } else { + user = &User{LowerName: strings.ToLower(username)} + } + + hasUser, err := x.Get(user) + if err != nil { + return nil, fmt.Errorf("get user record: %v", err) + } + + if hasUser { + // Note: This check is unnecessary but to reduce user confusion at login page + // and make it more consistent at user's perspective. + if loginSourceID >= 0 && user.LoginSource != loginSourceID { + return nil, errors.LoginSourceMismatch{loginSourceID, user.LoginSource} + } + + // Validate password hash fetched from database for local accounts + if user.LoginType == LOGIN_NOTYPE || + user.LoginType == LOGIN_PLAIN { + if user.ValidatePassword(password) { + return user, nil + } + + return nil, errors.UserNotExist{user.ID, user.Name} + } + + // Remote login to the login source the user is associated with + source, err := GetLoginSourceByID(user.LoginSource) + if err != nil { + return nil, err + } + + return remoteUserLogin(user, user.LoginName, password, source, false) + } + + // Non-local login source is always greater than 0 + if loginSourceID <= 0 { + return nil, errors.UserNotExist{-1, username} + } + + source, err := GetLoginSourceByID(loginSourceID) + if err != nil { + return nil, err + } + + return remoteUserLogin(nil, username, password, source, true) +} diff --git a/internal/db/migrations/migrations.go b/internal/db/migrations/migrations.go new file mode 100644 index 00000000..79534484 --- /dev/null +++ b/internal/db/migrations/migrations.go @@ -0,0 +1,390 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package migrations + +import ( + "fmt" + "strings" + "time" + + "github.com/unknwon/com" + log "gopkg.in/clog.v1" + "xorm.io/xorm" + + "gogs.io/gogs/internal/tool" +) + +const _MIN_DB_VER = 10 + +type Migration interface { + Description() string + Migrate(*xorm.Engine) error +} + +type migration struct { + description string + migrate func(*xorm.Engine) error +} + +func NewMigration(desc string, fn func(*xorm.Engine) error) Migration { + return &migration{desc, fn} +} + +func (m *migration) Description() string { + return m.description +} + +func (m *migration) Migrate(x *xorm.Engine) error { + return m.migrate(x) +} + +// The version table. Should have only one row with id==1 +type Version struct { + ID int64 + Version int64 +} + +// This is a sequence of migrations. Add new migrations to the bottom of the list. +// If you want to "retire" a migration, remove it from the top of the list and +// update _MIN_VER_DB accordingly +var migrations = []Migration{ + // v0 -> v4 : before 0.6.0 -> last support 0.7.33 + // v4 -> v10: before 0.7.0 -> last support 0.9.141 + NewMigration("generate rands and salt for organizations", generateOrgRandsAndSalt), // V10 -> V11:v0.8.5 + NewMigration("convert date to unix timestamp", convertDateToUnix), // V11 -> V12:v0.9.2 + NewMigration("convert LDAP UseSSL option to SecurityProtocol", ldapUseSSLToSecurityProtocol), // V12 -> V13:v0.9.37 + + // v13 -> v14:v0.9.87 + NewMigration("set comment updated with created", setCommentUpdatedWithCreated), + // v14 -> v15:v0.9.147 + NewMigration("generate and migrate Git hooks", generateAndMigrateGitHooks), + // v15 -> v16:v0.10.16 + NewMigration("update repository sizes", updateRepositorySizes), + // v16 -> v17:v0.10.31 + NewMigration("remove invalid protect branch whitelist", removeInvalidProtectBranchWhitelist), + // v17 -> v18:v0.11.48 + NewMigration("store long text in repository description field", updateRepositoryDescriptionField), + // v18 -> v19:v0.11.55 + NewMigration("clean unlinked webhook and hook_tasks", cleanUnlinkedWebhookAndHookTasks), +} + +// Migrate database to current version +func Migrate(x *xorm.Engine) error { + if err := x.Sync(new(Version)); err != nil { + return fmt.Errorf("sync: %v", err) + } + + currentVersion := &Version{ID: 1} + has, err := x.Get(currentVersion) + if err != nil { + return fmt.Errorf("get: %v", err) + } else if !has { + // If the version record does not exist we think + // it is a fresh installation and we can skip all migrations. + currentVersion.ID = 0 + currentVersion.Version = int64(_MIN_DB_VER + len(migrations)) + + if _, err = x.InsertOne(currentVersion); err != nil { + return fmt.Errorf("insert: %v", err) + } + } + + v := currentVersion.Version + if _MIN_DB_VER > v { + log.Fatal(0, ` +Hi there, thank you for using Gogs for so long! +However, Gogs has stopped supporting auto-migration from your previously installed version. +But the good news is, it's very easy to fix this problem! +You can migrate your older database using a previous release, then you can upgrade to the newest version. + +Please save following instructions to somewhere and start working: + +- If you were using below 0.6.0 (e.g. 0.5.x), download last supported archive from following link: + https://gogs.io/gogs/releases/tag/v0.7.33 +- If you were using below 0.7.0 (e.g. 0.6.x), download last supported archive from following link: + https://gogs.io/gogs/releases/tag/v0.9.141 + +Once finished downloading, + +1. Extract the archive and to upgrade steps as usual. +2. Run it once. To verify, you should see some migration traces. +3. Once it starts web server successfully, stop it. +4. Now it's time to put back the release archive you originally intent to upgrade. +5. Enjoy! + +In case you're stilling getting this notice, go through instructions again until it disappears.`) + return nil + } + + if int(v-_MIN_DB_VER) > len(migrations) { + // User downgraded Gogs. + currentVersion.Version = int64(len(migrations) + _MIN_DB_VER) + _, err = x.Id(1).Update(currentVersion) + return err + } + for i, m := range migrations[v-_MIN_DB_VER:] { + log.Info("Migration: %s", m.Description()) + if err = m.Migrate(x); err != nil { + return fmt.Errorf("do migrate: %v", err) + } + currentVersion.Version = v + int64(i) + 1 + if _, err = x.Id(1).Update(currentVersion); err != nil { + return err + } + } + return nil +} + +func generateOrgRandsAndSalt(x *xorm.Engine) (err error) { + type User struct { + ID int64 `xorm:"pk autoincr"` + Rands string `xorm:"VARCHAR(10)"` + Salt string `xorm:"VARCHAR(10)"` + } + + orgs := make([]*User, 0, 10) + if err = x.Where("type=1").And("rands=''").Find(&orgs); err != nil { + return fmt.Errorf("select all organizations: %v", err) + } + + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return err + } + + for _, org := range orgs { + if org.Rands, err = tool.RandomString(10); err != nil { + return err + } + if org.Salt, err = tool.RandomString(10); err != nil { + return err + } + if _, err = sess.ID(org.ID).Update(org); err != nil { + return err + } + } + + return sess.Commit() +} + +type TAction struct { + ID int64 `xorm:"pk autoincr"` + CreatedUnix int64 +} + +func (t *TAction) TableName() string { return "action" } + +type TNotice struct { + ID int64 `xorm:"pk autoincr"` + CreatedUnix int64 +} + +func (t *TNotice) TableName() string { return "notice" } + +type TComment struct { + ID int64 `xorm:"pk autoincr"` + CreatedUnix int64 +} + +func (t *TComment) TableName() string { return "comment" } + +type TIssue struct { + ID int64 `xorm:"pk autoincr"` + DeadlineUnix int64 + CreatedUnix int64 + UpdatedUnix int64 +} + +func (t *TIssue) TableName() string { return "issue" } + +type TMilestone struct { + ID int64 `xorm:"pk autoincr"` + DeadlineUnix int64 + ClosedDateUnix int64 +} + +func (t *TMilestone) TableName() string { return "milestone" } + +type TAttachment struct { + ID int64 `xorm:"pk autoincr"` + CreatedUnix int64 +} + +func (t *TAttachment) TableName() string { return "attachment" } + +type TLoginSource struct { + ID int64 `xorm:"pk autoincr"` + CreatedUnix int64 + UpdatedUnix int64 +} + +func (t *TLoginSource) TableName() string { return "login_source" } + +type TPull struct { + ID int64 `xorm:"pk autoincr"` + MergedUnix int64 +} + +func (t *TPull) TableName() string { return "pull_request" } + +type TRelease struct { + ID int64 `xorm:"pk autoincr"` + CreatedUnix int64 +} + +func (t *TRelease) TableName() string { return "release" } + +type TRepo struct { + ID int64 `xorm:"pk autoincr"` + CreatedUnix int64 + UpdatedUnix int64 +} + +func (t *TRepo) TableName() string { return "repository" } + +type TMirror struct { + ID int64 `xorm:"pk autoincr"` + UpdatedUnix int64 + NextUpdateUnix int64 +} + +func (t *TMirror) TableName() string { return "mirror" } + +type TPublicKey struct { + ID int64 `xorm:"pk autoincr"` + CreatedUnix int64 + UpdatedUnix int64 +} + +func (t *TPublicKey) TableName() string { return "public_key" } + +type TDeployKey struct { + ID int64 `xorm:"pk autoincr"` + CreatedUnix int64 + UpdatedUnix int64 +} + +func (t *TDeployKey) TableName() string { return "deploy_key" } + +type TAccessToken struct { + ID int64 `xorm:"pk autoincr"` + CreatedUnix int64 + UpdatedUnix int64 +} + +func (t *TAccessToken) TableName() string { return "access_token" } + +type TUser struct { + ID int64 `xorm:"pk autoincr"` + CreatedUnix int64 + UpdatedUnix int64 +} + +func (t *TUser) TableName() string { return "user" } + +type TWebhook struct { + ID int64 `xorm:"pk autoincr"` + CreatedUnix int64 + UpdatedUnix int64 +} + +func (t *TWebhook) TableName() string { return "webhook" } + +func convertDateToUnix(x *xorm.Engine) (err error) { + log.Info("This migration could take up to minutes, please be patient.") + type Bean struct { + ID int64 `xorm:"pk autoincr"` + Created time.Time + Updated time.Time + Merged time.Time + Deadline time.Time + ClosedDate time.Time + NextUpdate time.Time + } + + var tables = []struct { + name string + cols []string + bean interface{} + }{ + {"action", []string{"created"}, new(TAction)}, + {"notice", []string{"created"}, new(TNotice)}, + {"comment", []string{"created"}, new(TComment)}, + {"issue", []string{"deadline", "created", "updated"}, new(TIssue)}, + {"milestone", []string{"deadline", "closed_date"}, new(TMilestone)}, + {"attachment", []string{"created"}, new(TAttachment)}, + {"login_source", []string{"created", "updated"}, new(TLoginSource)}, + {"pull_request", []string{"merged"}, new(TPull)}, + {"release", []string{"created"}, new(TRelease)}, + {"repository", []string{"created", "updated"}, new(TRepo)}, + {"mirror", []string{"updated", "next_update"}, new(TMirror)}, + {"public_key", []string{"created", "updated"}, new(TPublicKey)}, + {"deploy_key", []string{"created", "updated"}, new(TDeployKey)}, + {"access_token", []string{"created", "updated"}, new(TAccessToken)}, + {"user", []string{"created", "updated"}, new(TUser)}, + {"webhook", []string{"created", "updated"}, new(TWebhook)}, + } + + for _, table := range tables { + log.Info("Converting table: %s", table.name) + if err = x.Sync2(table.bean); err != nil { + return fmt.Errorf("Sync [table: %s]: %v", table.name, err) + } + + offset := 0 + for { + beans := make([]*Bean, 0, 100) + if err = x.Sql(fmt.Sprintf("SELECT * FROM `%s` ORDER BY id ASC LIMIT 100 OFFSET %d", + table.name, offset)).Find(&beans); err != nil { + return fmt.Errorf("select beans [table: %s, offset: %d]: %v", table.name, offset, err) + } + log.Trace("Table [%s]: offset: %d, beans: %d", table.name, offset, len(beans)) + if len(beans) == 0 { + break + } + offset += 100 + + baseSQL := "UPDATE `" + table.name + "` SET " + for _, bean := range beans { + valSQLs := make([]string, 0, len(table.cols)) + for _, col := range table.cols { + fieldSQL := "" + fieldSQL += col + "_unix = " + + switch col { + case "deadline": + if bean.Deadline.IsZero() { + continue + } + fieldSQL += com.ToStr(bean.Deadline.Unix()) + case "created": + fieldSQL += com.ToStr(bean.Created.Unix()) + case "updated": + fieldSQL += com.ToStr(bean.Updated.Unix()) + case "closed_date": + fieldSQL += com.ToStr(bean.ClosedDate.Unix()) + case "merged": + fieldSQL += com.ToStr(bean.Merged.Unix()) + case "next_update": + fieldSQL += com.ToStr(bean.NextUpdate.Unix()) + } + + valSQLs = append(valSQLs, fieldSQL) + } + + if len(valSQLs) == 0 { + continue + } + + if _, err = x.Exec(baseSQL + strings.Join(valSQLs, ",") + " WHERE id = " + com.ToStr(bean.ID)); err != nil { + return fmt.Errorf("update bean [table: %s, id: %d]: %v", table.name, bean.ID, err) + } + } + } + } + + return nil +} diff --git a/internal/db/migrations/v13.go b/internal/db/migrations/v13.go new file mode 100644 index 00000000..1097956e --- /dev/null +++ b/internal/db/migrations/v13.go @@ -0,0 +1,52 @@ +// Copyright 2016 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package migrations + +import ( + "fmt" + "strings" + + "github.com/unknwon/com" + "xorm.io/xorm" + "github.com/json-iterator/go" +) + +func ldapUseSSLToSecurityProtocol(x *xorm.Engine) error { + results, err := x.Query("SELECT `id`,`cfg` FROM `login_source` WHERE `type` = 2 OR `type` = 5") + if err != nil { + if strings.Contains(err.Error(), "no such column") { + return nil + } + return fmt.Errorf("select LDAP login sources: %v", err) + } + + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return err + } + + for _, result := range results { + cfg := map[string]interface{}{} + if err = jsoniter.Unmarshal(result["cfg"], &cfg); err != nil { + return fmt.Errorf("unmarshal JSON config: %v", err) + } + if com.ToStr(cfg["UseSSL"]) == "true" { + cfg["SecurityProtocol"] = 1 // LDAPS + } + delete(cfg, "UseSSL") + + data, err := jsoniter.Marshal(&cfg) + if err != nil { + return fmt.Errorf("marshal JSON config: %v", err) + } + + if _, err = sess.Exec("UPDATE `login_source` SET `cfg`=? WHERE `id`=?", + string(data), com.StrTo(result["id"]).MustInt64()); err != nil { + return fmt.Errorf("update config column: %v", err) + } + } + return sess.Commit() +} diff --git a/internal/db/migrations/v14.go b/internal/db/migrations/v14.go new file mode 100644 index 00000000..de8babed --- /dev/null +++ b/internal/db/migrations/v14.go @@ -0,0 +1,24 @@ +// Copyright 2016 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package migrations + +import ( + "fmt" + + "xorm.io/xorm" +) + +func setCommentUpdatedWithCreated(x *xorm.Engine) (err error) { + type Comment struct { + UpdatedUnix int64 + } + + if err = x.Sync2(new(Comment)); err != nil { + return fmt.Errorf("Sync2: %v", err) + } else if _, err = x.Exec("UPDATE comment SET updated_unix = created_unix"); err != nil { + return fmt.Errorf("set update_unix: %v", err) + } + return nil +} diff --git a/internal/db/migrations/v15.go b/internal/db/migrations/v15.go new file mode 100644 index 00000000..7f3b9504 --- /dev/null +++ b/internal/db/migrations/v15.go @@ -0,0 +1,104 @@ +// Copyright 2017 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package migrations + +import ( + "fmt" + "io/ioutil" + "os" + "path/filepath" + "strings" + + "github.com/unknwon/com" + "xorm.io/xorm" + log "gopkg.in/clog.v1" + + "gogs.io/gogs/internal/setting" +) + +func generateAndMigrateGitHooks(x *xorm.Engine) (err error) { + type Repository struct { + ID int64 + OwnerID int64 + Name string + } + type User struct { + ID int64 + Name string + } + var ( + hookNames = []string{"pre-receive", "update", "post-receive"} + hookTpls = []string{ + fmt.Sprintf("#!/usr/bin/env %s\n\"%s\" hook --config='%s' pre-receive\n", setting.ScriptType, setting.AppPath, setting.CustomConf), + fmt.Sprintf("#!/usr/bin/env %s\n\"%s\" hook --config='%s' update $1 $2 $3\n", setting.ScriptType, setting.AppPath, setting.CustomConf), + fmt.Sprintf("#!/usr/bin/env %s\n\"%s\" hook --config='%s' post-receive\n", setting.ScriptType, setting.AppPath, setting.CustomConf), + } + ) + + // Cleanup old update.log and http.log files. + filepath.Walk(setting.LogRootPath, func(path string, info os.FileInfo, err error) error { + if !info.IsDir() && + (strings.HasPrefix(filepath.Base(path), "update.log") || + strings.HasPrefix(filepath.Base(path), "http.log")) { + os.Remove(path) + } + return nil + }) + + return x.Where("id > 0").Iterate(new(Repository), + func(idx int, bean interface{}) error { + repo := bean.(*Repository) + if repo.Name == "." || repo.Name == ".." { + return nil + } + + user := new(User) + has, err := x.Where("id = ?", repo.OwnerID).Get(user) + if err != nil { + return fmt.Errorf("query owner of repository [repo_id: %d, owner_id: %d]: %v", repo.ID, repo.OwnerID, err) + } else if !has { + return nil + } + + repoBase := filepath.Join(setting.RepoRootPath, strings.ToLower(user.Name), strings.ToLower(repo.Name)) + repoPath := repoBase + ".git" + wikiPath := repoBase + ".wiki.git" + log.Trace("[%04d]: %s", idx, repoPath) + + // Note: we should not create hookDir here because update hook file should already exists inside this direcotry, + // if this directory does not exist, the current setup is not correct anyway. + hookDir := filepath.Join(repoPath, "hooks") + customHookDir := filepath.Join(repoPath, "custom_hooks") + wikiHookDir := filepath.Join(wikiPath, "hooks") + + for i, hookName := range hookNames { + oldHookPath := filepath.Join(hookDir, hookName) + newHookPath := filepath.Join(customHookDir, hookName) + + // Gogs didn't allow user to set custom update hook thus no migration for it. + // In case user runs this migration multiple times, and custom hook exists, + // we assume it's been migrated already. + if hookName != "update" && com.IsFile(oldHookPath) && !com.IsExist(customHookDir) { + os.MkdirAll(customHookDir, os.ModePerm) + if err = os.Rename(oldHookPath, newHookPath); err != nil { + return fmt.Errorf("move hook file to custom directory '%s' -> '%s': %v", oldHookPath, newHookPath, err) + } + } + + if err = ioutil.WriteFile(oldHookPath, []byte(hookTpls[i]), os.ModePerm); err != nil { + return fmt.Errorf("write hook file '%s': %v", oldHookPath, err) + } + + if com.IsDir(wikiPath) { + os.MkdirAll(wikiHookDir, os.ModePerm) + wikiHookPath := filepath.Join(wikiHookDir, hookName) + if err = ioutil.WriteFile(wikiHookPath, []byte(hookTpls[i]), os.ModePerm); err != nil { + return fmt.Errorf("write wiki hook file '%s': %v", wikiHookPath, err) + } + } + } + return nil + }) +} diff --git a/internal/db/migrations/v16.go b/internal/db/migrations/v16.go new file mode 100644 index 00000000..b374be39 --- /dev/null +++ b/internal/db/migrations/v16.go @@ -0,0 +1,77 @@ +// Copyright 2017 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package migrations + +import ( + "fmt" + "path/filepath" + "strings" + + "xorm.io/xorm" + log "gopkg.in/clog.v1" + + "github.com/gogs/git-module" + + "gogs.io/gogs/internal/setting" +) + +func updateRepositorySizes(x *xorm.Engine) (err error) { + log.Info("This migration could take up to minutes, please be patient.") + type Repository struct { + ID int64 + OwnerID int64 + Name string + Size int64 + } + type User struct { + ID int64 + Name string + } + if err = x.Sync2(new(Repository)); err != nil { + return fmt.Errorf("Sync2: %v", err) + } + + // For the sake of SQLite3, we can't use x.Iterate here. + offset := 0 + for { + repos := make([]*Repository, 0, 10) + if err = x.Sql(fmt.Sprintf("SELECT * FROM `repository` ORDER BY id ASC LIMIT 10 OFFSET %d", offset)). + Find(&repos); err != nil { + return fmt.Errorf("select repos [offset: %d]: %v", offset, err) + } + log.Trace("Select [offset: %d, repos: %d]", offset, len(repos)) + if len(repos) == 0 { + break + } + offset += 10 + + for _, repo := range repos { + if repo.Name == "." || repo.Name == ".." { + continue + } + + user := new(User) + has, err := x.Where("id = ?", repo.OwnerID).Get(user) + if err != nil { + return fmt.Errorf("query owner of repository [repo_id: %d, owner_id: %d]: %v", repo.ID, repo.OwnerID, err) + } else if !has { + continue + } + + repoPath := filepath.Join(setting.RepoRootPath, strings.ToLower(user.Name), strings.ToLower(repo.Name)) + ".git" + countObject, err := git.GetRepoSize(repoPath) + if err != nil { + log.Warn("GetRepoSize: %v", err) + continue + } + + repo.Size = countObject.Size + countObject.SizePack + if _, err = x.Id(repo.ID).Cols("size").Update(repo); err != nil { + return fmt.Errorf("update size: %v", err) + } + } + } + return nil +} diff --git a/internal/db/migrations/v17.go b/internal/db/migrations/v17.go new file mode 100644 index 00000000..279ddf25 --- /dev/null +++ b/internal/db/migrations/v17.go @@ -0,0 +1,22 @@ +// Copyright 2017 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package migrations + +import ( + "fmt" + + "xorm.io/xorm" +) + +func removeInvalidProtectBranchWhitelist(x *xorm.Engine) error { + exist, err := x.IsTableExist("protect_branch_whitelist") + if err != nil { + return fmt.Errorf("IsTableExist: %v", err) + } else if !exist { + return nil + } + _, err = x.Exec("DELETE FROM protect_branch_whitelist WHERE protect_branch_id = 0") + return err +} diff --git a/internal/db/migrations/v18.go b/internal/db/migrations/v18.go new file mode 100644 index 00000000..9ebb46ed --- /dev/null +++ b/internal/db/migrations/v18.go @@ -0,0 +1,34 @@ +// Copyright 2018 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package migrations + +import ( + "fmt" + + "xorm.io/xorm" + + "gogs.io/gogs/internal/setting" +) + +func updateRepositoryDescriptionField(x *xorm.Engine) error { + exist, err := x.IsTableExist("repository") + if err != nil { + return fmt.Errorf("IsTableExist: %v", err) + } else if !exist { + return nil + } + switch { + case setting.UseMySQL: + _, err = x.Exec("ALTER TABLE `repository` MODIFY `description` VARCHAR(512);") + case setting.UseMSSQL: + _, err = x.Exec("ALTER TABLE `repository` ALTER COLUMN `description` VARCHAR(512);") + case setting.UsePostgreSQL: + _, err = x.Exec("ALTER TABLE `repository` ALTER COLUMN `description` TYPE VARCHAR(512);") + case setting.UseSQLite3: + // Sqlite3 uses TEXT type by default for any string type field. + // Keep this comment to mention that we don't missed any option. + } + return err +} diff --git a/internal/db/migrations/v19.go b/internal/db/migrations/v19.go new file mode 100644 index 00000000..bae2e355 --- /dev/null +++ b/internal/db/migrations/v19.go @@ -0,0 +1,18 @@ +// Copyright 2018 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package migrations + +import ( + "xorm.io/xorm" +) + +func cleanUnlinkedWebhookAndHookTasks(x *xorm.Engine) error { + _, err := x.Exec(`DELETE FROM webhook WHERE repo_id NOT IN (SELECT id FROM repository);`) + if err != nil { + return err + } + _, err = x.Exec(`DELETE FROM hook_task WHERE repo_id NOT IN (SELECT id FROM repository);`) + return err +} diff --git a/internal/db/milestone.go b/internal/db/milestone.go new file mode 100644 index 00000000..10c5c556 --- /dev/null +++ b/internal/db/milestone.go @@ -0,0 +1,402 @@ +// Copyright 2017 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package db + +import ( + "fmt" + "time" + + log "gopkg.in/clog.v1" + "xorm.io/xorm" + + api "github.com/gogs/go-gogs-client" + + "gogs.io/gogs/internal/setting" +) + +// Milestone represents a milestone of repository. +type Milestone struct { + ID int64 + RepoID int64 `xorm:"INDEX"` + Name string + Content string `xorm:"TEXT"` + RenderedContent string `xorm:"-" json:"-"` + IsClosed bool + NumIssues int + NumClosedIssues int + NumOpenIssues int `xorm:"-" json:"-"` + Completeness int // Percentage(1-100). + IsOverDue bool `xorm:"-" json:"-"` + + DeadlineString string `xorm:"-" json:"-"` + Deadline time.Time `xorm:"-" json:"-"` + DeadlineUnix int64 + ClosedDate time.Time `xorm:"-" json:"-"` + ClosedDateUnix int64 +} + +func (m *Milestone) BeforeInsert() { + m.DeadlineUnix = m.Deadline.Unix() +} + +func (m *Milestone) BeforeUpdate() { + if m.NumIssues > 0 { + m.Completeness = m.NumClosedIssues * 100 / m.NumIssues + } else { + m.Completeness = 0 + } + + m.DeadlineUnix = m.Deadline.Unix() + m.ClosedDateUnix = m.ClosedDate.Unix() +} + +func (m *Milestone) AfterSet(colName string, _ xorm.Cell) { + switch colName { + case "num_closed_issues": + m.NumOpenIssues = m.NumIssues - m.NumClosedIssues + + case "deadline_unix": + m.Deadline = time.Unix(m.DeadlineUnix, 0).Local() + if m.Deadline.Year() == 9999 { + return + } + + m.DeadlineString = m.Deadline.Format("2006-01-02") + if time.Now().Local().After(m.Deadline) { + m.IsOverDue = true + } + + case "closed_date_unix": + m.ClosedDate = time.Unix(m.ClosedDateUnix, 0).Local() + } +} + +// State returns string representation of milestone status. +func (m *Milestone) State() api.StateType { + if m.IsClosed { + return api.STATE_CLOSED + } + return api.STATE_OPEN +} + +func (m *Milestone) ChangeStatus(isClosed bool) error { + return ChangeMilestoneStatus(m, isClosed) +} + +func (m *Milestone) APIFormat() *api.Milestone { + apiMilestone := &api.Milestone{ + ID: m.ID, + State: m.State(), + Title: m.Name, + Description: m.Content, + OpenIssues: m.NumOpenIssues, + ClosedIssues: m.NumClosedIssues, + } + if m.IsClosed { + apiMilestone.Closed = &m.ClosedDate + } + if m.Deadline.Year() < 9999 { + apiMilestone.Deadline = &m.Deadline + } + return apiMilestone +} + +func (m *Milestone) CountIssues(isClosed, includePulls bool) int64 { + sess := x.Where("milestone_id = ?", m.ID).And("is_closed = ?", isClosed) + if !includePulls { + sess.And("is_pull = ?", false) + } + count, _ := sess.Count(new(Issue)) + return count +} + +// NewMilestone creates new milestone of repository. +func NewMilestone(m *Milestone) (err error) { + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return err + } + + if _, err = sess.Insert(m); err != nil { + return err + } + + if _, err = sess.Exec("UPDATE `repository` SET num_milestones = num_milestones + 1 WHERE id = ?", m.RepoID); err != nil { + return err + } + return sess.Commit() +} + +func getMilestoneByRepoID(e Engine, repoID, id int64) (*Milestone, error) { + m := &Milestone{ + ID: id, + RepoID: repoID, + } + has, err := e.Get(m) + if err != nil { + return nil, err + } else if !has { + return nil, ErrMilestoneNotExist{id, repoID} + } + return m, nil +} + +// GetWebhookByRepoID returns the milestone in a repository. +func GetMilestoneByRepoID(repoID, id int64) (*Milestone, error) { + return getMilestoneByRepoID(x, repoID, id) +} + +// GetMilestonesByRepoID returns all milestones of a repository. +func GetMilestonesByRepoID(repoID int64) ([]*Milestone, error) { + miles := make([]*Milestone, 0, 10) + return miles, x.Where("repo_id = ?", repoID).Find(&miles) +} + +// GetMilestones returns a list of milestones of given repository and status. +func GetMilestones(repoID int64, page int, isClosed bool) ([]*Milestone, error) { + miles := make([]*Milestone, 0, setting.UI.IssuePagingNum) + sess := x.Where("repo_id = ? AND is_closed = ?", repoID, isClosed) + if page > 0 { + sess = sess.Limit(setting.UI.IssuePagingNum, (page-1)*setting.UI.IssuePagingNum) + } + return miles, sess.Find(&miles) +} + +func updateMilestone(e Engine, m *Milestone) error { + _, err := e.ID(m.ID).AllCols().Update(m) + return err +} + +// UpdateMilestone updates information of given milestone. +func UpdateMilestone(m *Milestone) error { + return updateMilestone(x, m) +} + +func countRepoMilestones(e Engine, repoID int64) int64 { + count, _ := e.Where("repo_id=?", repoID).Count(new(Milestone)) + return count +} + +// CountRepoMilestones returns number of milestones in given repository. +func CountRepoMilestones(repoID int64) int64 { + return countRepoMilestones(x, repoID) +} + +func countRepoClosedMilestones(e Engine, repoID int64) int64 { + closed, _ := e.Where("repo_id=? AND is_closed=?", repoID, true).Count(new(Milestone)) + return closed +} + +// CountRepoClosedMilestones returns number of closed milestones in given repository. +func CountRepoClosedMilestones(repoID int64) int64 { + return countRepoClosedMilestones(x, repoID) +} + +// MilestoneStats returns number of open and closed milestones of given repository. +func MilestoneStats(repoID int64) (open int64, closed int64) { + open, _ = x.Where("repo_id=? AND is_closed=?", repoID, false).Count(new(Milestone)) + return open, CountRepoClosedMilestones(repoID) +} + +// ChangeMilestoneStatus changes the milestone open/closed status. +// If milestone passes with changed values, those values will be +// updated to database as well. +func ChangeMilestoneStatus(m *Milestone, isClosed bool) (err error) { + repo, err := GetRepositoryByID(m.RepoID) + if err != nil { + return err + } + + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return err + } + + m.IsClosed = isClosed + if err = updateMilestone(sess, m); err != nil { + return err + } + + repo.NumMilestones = int(countRepoMilestones(sess, repo.ID)) + repo.NumClosedMilestones = int(countRepoClosedMilestones(sess, repo.ID)) + if _, err = sess.ID(repo.ID).AllCols().Update(repo); err != nil { + return err + } + return sess.Commit() +} + +func changeMilestoneIssueStats(e *xorm.Session, issue *Issue) error { + if issue.MilestoneID == 0 { + return nil + } + + m, err := getMilestoneByRepoID(e, issue.RepoID, issue.MilestoneID) + if err != nil { + return err + } + + if issue.IsClosed { + m.NumOpenIssues-- + m.NumClosedIssues++ + } else { + m.NumOpenIssues++ + m.NumClosedIssues-- + } + + return updateMilestone(e, m) +} + +// ChangeMilestoneIssueStats updates the open/closed issues counter and progress +// for the milestone associated with the given issue. +func ChangeMilestoneIssueStats(issue *Issue) (err error) { + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return err + } + + if err = changeMilestoneIssueStats(sess, issue); err != nil { + return err + } + + return sess.Commit() +} + +func changeMilestoneAssign(e *xorm.Session, issue *Issue, oldMilestoneID int64) error { + if oldMilestoneID > 0 { + m, err := getMilestoneByRepoID(e, issue.RepoID, oldMilestoneID) + if err != nil { + return err + } + + m.NumIssues-- + if issue.IsClosed { + m.NumClosedIssues-- + } + + if err = updateMilestone(e, m); err != nil { + return err + } else if _, err = e.Exec("UPDATE `issue_user` SET milestone_id = 0 WHERE issue_id = ?", issue.ID); err != nil { + return err + } + + issue.Milestone = nil + } + + if issue.MilestoneID > 0 { + m, err := getMilestoneByRepoID(e, issue.RepoID, issue.MilestoneID) + if err != nil { + return err + } + + m.NumIssues++ + if issue.IsClosed { + m.NumClosedIssues++ + } + + if err = updateMilestone(e, m); err != nil { + return err + } else if _, err = e.Exec("UPDATE `issue_user` SET milestone_id = ? WHERE issue_id = ?", m.ID, issue.ID); err != nil { + return err + } + + issue.Milestone = m + } + + return updateIssue(e, issue) +} + +// ChangeMilestoneAssign changes assignment of milestone for issue. +func ChangeMilestoneAssign(doer *User, issue *Issue, oldMilestoneID int64) (err error) { + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return err + } + + if err = changeMilestoneAssign(sess, issue, oldMilestoneID); err != nil { + return err + } + + if err = sess.Commit(); err != nil { + return fmt.Errorf("Commit: %v", err) + } + + var hookAction api.HookIssueAction + if issue.MilestoneID > 0 { + hookAction = api.HOOK_ISSUE_MILESTONED + } else { + hookAction = api.HOOK_ISSUE_DEMILESTONED + } + + if issue.IsPull { + err = issue.PullRequest.LoadIssue() + if err != nil { + log.Error(2, "LoadIssue: %v", err) + return + } + err = PrepareWebhooks(issue.Repo, HOOK_EVENT_PULL_REQUEST, &api.PullRequestPayload{ + Action: hookAction, + Index: issue.Index, + PullRequest: issue.PullRequest.APIFormat(), + Repository: issue.Repo.APIFormat(nil), + Sender: doer.APIFormat(), + }) + } else { + err = PrepareWebhooks(issue.Repo, HOOK_EVENT_ISSUES, &api.IssuesPayload{ + Action: hookAction, + Index: issue.Index, + Issue: issue.APIFormat(), + Repository: issue.Repo.APIFormat(nil), + Sender: doer.APIFormat(), + }) + } + if err != nil { + log.Error(2, "PrepareWebhooks [is_pull: %v]: %v", issue.IsPull, err) + } + + return nil +} + +// DeleteMilestoneOfRepoByID deletes a milestone from a repository. +func DeleteMilestoneOfRepoByID(repoID, id int64) error { + m, err := GetMilestoneByRepoID(repoID, id) + if err != nil { + if IsErrMilestoneNotExist(err) { + return nil + } + return err + } + + repo, err := GetRepositoryByID(m.RepoID) + if err != nil { + return err + } + + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return err + } + + if _, err = sess.ID(m.ID).Delete(new(Milestone)); err != nil { + return err + } + + repo.NumMilestones = int(countRepoMilestones(sess, repo.ID)) + repo.NumClosedMilestones = int(countRepoClosedMilestones(sess, repo.ID)) + if _, err = sess.ID(repo.ID).AllCols().Update(repo); err != nil { + return err + } + + if _, err = sess.Exec("UPDATE `issue` SET milestone_id = 0 WHERE milestone_id = ?", m.ID); err != nil { + return err + } else if _, err = sess.Exec("UPDATE `issue_user` SET milestone_id = 0 WHERE milestone_id = ?", m.ID); err != nil { + return err + } + return sess.Commit() +} diff --git a/internal/db/mirror.go b/internal/db/mirror.go new file mode 100644 index 00000000..b165cbfc --- /dev/null +++ b/internal/db/mirror.go @@ -0,0 +1,498 @@ +// Copyright 2016 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package db + +import ( + "container/list" + "fmt" + "net/url" + "strings" + "time" + + "github.com/unknwon/com" + log "gopkg.in/clog.v1" + "gopkg.in/ini.v1" + "xorm.io/xorm" + + "github.com/gogs/git-module" + + "gogs.io/gogs/internal/db/errors" + "gogs.io/gogs/internal/process" + "gogs.io/gogs/internal/setting" + "gogs.io/gogs/internal/sync" +) + +var MirrorQueue = sync.NewUniqueQueue(setting.Repository.MirrorQueueLength) + +// Mirror represents mirror information of a repository. +type Mirror struct { + ID int64 + RepoID int64 + Repo *Repository `xorm:"-" json:"-"` + Interval int // Hour. + EnablePrune bool `xorm:"NOT NULL DEFAULT true"` + + // Last and next sync time of Git data from upstream + LastSync time.Time `xorm:"-" json:"-"` + LastSyncUnix int64 `xorm:"updated_unix"` + NextSync time.Time `xorm:"-" json:"-"` + NextSyncUnix int64 `xorm:"next_update_unix"` + + address string `xorm:"-" json:"-"` +} + +func (m *Mirror) BeforeInsert() { + m.NextSyncUnix = m.NextSync.Unix() +} + +func (m *Mirror) BeforeUpdate() { + m.LastSyncUnix = m.LastSync.Unix() + m.NextSyncUnix = m.NextSync.Unix() +} + +func (m *Mirror) AfterSet(colName string, _ xorm.Cell) { + var err error + switch colName { + case "repo_id": + m.Repo, err = GetRepositoryByID(m.RepoID) + if err != nil { + log.Error(3, "GetRepositoryByID [%d]: %v", m.ID, err) + } + case "updated_unix": + m.LastSync = time.Unix(m.LastSyncUnix, 0).Local() + case "next_update_unix": + m.NextSync = time.Unix(m.NextSyncUnix, 0).Local() + } +} + +// ScheduleNextSync calculates and sets next sync time based on repostiroy mirror setting. +func (m *Mirror) ScheduleNextSync() { + m.NextSync = time.Now().Add(time.Duration(m.Interval) * time.Hour) +} + +// findPasswordInMirrorAddress returns start (inclusive) and end index (exclusive) +// of password portion of credentials in given mirror address. +// It returns a boolean value to indicate whether password portion is found. +func findPasswordInMirrorAddress(addr string) (start int, end int, found bool) { + // Find end of credentials (start of path) + end = strings.LastIndex(addr, "@") + if end == -1 { + return -1, -1, false + } + + // Find delimiter of credentials (end of username) + start = strings.Index(addr, "://") + if start == -1 { + return -1, -1, false + } + start += 3 + delim := strings.Index(addr[start:], ":") + if delim == -1 { + return -1, -1, false + } + delim += 1 + + if start+delim >= end { + return -1, -1, false // No password portion presented + } + + return start + delim, end, true +} + +// unescapeMirrorCredentials returns mirror address with unescaped credentials. +func unescapeMirrorCredentials(addr string) string { + start, end, found := findPasswordInMirrorAddress(addr) + if !found { + return addr + } + + password, _ := url.QueryUnescape(addr[start:end]) + return addr[:start] + password + addr[end:] +} + +func (m *Mirror) readAddress() { + if len(m.address) > 0 { + return + } + + cfg, err := ini.Load(m.Repo.GitConfigPath()) + if err != nil { + log.Error(2, "Load: %v", err) + return + } + m.address = cfg.Section("remote \"origin\"").Key("url").Value() +} + +// HandleMirrorCredentials replaces user credentials from HTTP/HTTPS URL +// with placeholder <credentials>. +// It returns original string if protocol is not HTTP/HTTPS. +func HandleMirrorCredentials(url string, mosaics bool) string { + i := strings.Index(url, "@") + if i == -1 { + return url + } + start := strings.Index(url, "://") + if start == -1 { + return url + } + if mosaics { + return url[:start+3] + "<credentials>" + url[i:] + } + return url[:start+3] + url[i+1:] +} + +// Address returns mirror address from Git repository config without credentials. +func (m *Mirror) Address() string { + m.readAddress() + return HandleMirrorCredentials(m.address, false) +} + +// MosaicsAddress returns mirror address from Git repository config with credentials under mosaics. +func (m *Mirror) MosaicsAddress() string { + m.readAddress() + return HandleMirrorCredentials(m.address, true) +} + +// RawAddress returns raw mirror address directly from Git repository config. +func (m *Mirror) RawAddress() string { + m.readAddress() + return m.address +} + +// FullAddress returns mirror address from Git repository config with unescaped credentials. +func (m *Mirror) FullAddress() string { + m.readAddress() + return unescapeMirrorCredentials(m.address) +} + +// escapeCredentials returns mirror address with escaped credentials. +func escapeMirrorCredentials(addr string) string { + start, end, found := findPasswordInMirrorAddress(addr) + if !found { + return addr + } + + return addr[:start] + url.QueryEscape(addr[start:end]) + addr[end:] +} + +// SaveAddress writes new address to Git repository config. +func (m *Mirror) SaveAddress(addr string) error { + configPath := m.Repo.GitConfigPath() + cfg, err := ini.Load(configPath) + if err != nil { + return fmt.Errorf("Load: %v", err) + } + + cfg.Section(`remote "origin"`).Key("url").SetValue(escapeMirrorCredentials(addr)) + return cfg.SaveToIndent(configPath, "\t") +} + +const GIT_SHORT_EMPTY_SHA = "0000000" + +// mirrorSyncResult contains information of a updated reference. +// If the oldCommitID is "0000000", it means a new reference, the value of newCommitID is empty. +// If the newCommitID is "0000000", it means the reference is deleted, the value of oldCommitID is empty. +type mirrorSyncResult struct { + refName string + oldCommitID string + newCommitID string +} + +// parseRemoteUpdateOutput detects create, update and delete operations of references from upstream. +func parseRemoteUpdateOutput(output string) []*mirrorSyncResult { + results := make([]*mirrorSyncResult, 0, 3) + lines := strings.Split(output, "\n") + for i := range lines { + // Make sure reference name is presented before continue + idx := strings.Index(lines[i], "-> ") + if idx == -1 { + continue + } + + refName := lines[i][idx+3:] + switch { + case strings.HasPrefix(lines[i], " * "): // New reference + results = append(results, &mirrorSyncResult{ + refName: refName, + oldCommitID: GIT_SHORT_EMPTY_SHA, + }) + case strings.HasPrefix(lines[i], " - "): // Delete reference + results = append(results, &mirrorSyncResult{ + refName: refName, + newCommitID: GIT_SHORT_EMPTY_SHA, + }) + case strings.HasPrefix(lines[i], " "): // New commits of a reference + delimIdx := strings.Index(lines[i][3:], " ") + if delimIdx == -1 { + log.Error(2, "SHA delimiter not found: %q", lines[i]) + continue + } + shas := strings.Split(lines[i][3:delimIdx+3], "..") + if len(shas) != 2 { + log.Error(2, "Expect two SHAs but not what found: %q", lines[i]) + continue + } + results = append(results, &mirrorSyncResult{ + refName: refName, + oldCommitID: shas[0], + newCommitID: shas[1], + }) + + default: + log.Warn("parseRemoteUpdateOutput: unexpected update line %q", lines[i]) + } + } + return results +} + +// runSync returns true if sync finished without error. +func (m *Mirror) runSync() ([]*mirrorSyncResult, bool) { + repoPath := m.Repo.RepoPath() + wikiPath := m.Repo.WikiPath() + timeout := time.Duration(setting.Git.Timeout.Mirror) * time.Second + + // Do a fast-fail testing against on repository URL to ensure it is accessible under + // good condition to prevent long blocking on URL resolution without syncing anything. + if !git.IsRepoURLAccessible(git.NetworkOptions{ + URL: m.RawAddress(), + Timeout: 10 * time.Second, + }) { + desc := fmt.Sprintf("Source URL of mirror repository '%s' is not accessible: %s", m.Repo.FullName(), m.MosaicsAddress()) + if err := CreateRepositoryNotice(desc); err != nil { + log.Error(2, "CreateRepositoryNotice: %v", err) + } + return nil, false + } + + gitArgs := []string{"remote", "update"} + if m.EnablePrune { + gitArgs = append(gitArgs, "--prune") + } + _, stderr, err := process.ExecDir( + timeout, repoPath, fmt.Sprintf("Mirror.runSync: %s", repoPath), + "git", gitArgs...) + if err != nil { + desc := fmt.Sprintf("Fail to update mirror repository '%s': %s", repoPath, stderr) + log.Error(2, desc) + if err = CreateRepositoryNotice(desc); err != nil { + log.Error(2, "CreateRepositoryNotice: %v", err) + } + return nil, false + } + output := stderr + + if err := m.Repo.UpdateSize(); err != nil { + log.Error(2, "UpdateSize [repo_id: %d]: %v", m.Repo.ID, err) + } + + if m.Repo.HasWiki() { + // Even if wiki sync failed, we still want results from the main repository + if _, stderr, err := process.ExecDir( + timeout, wikiPath, fmt.Sprintf("Mirror.runSync: %s", wikiPath), + "git", "remote", "update", "--prune"); err != nil { + desc := fmt.Sprintf("Fail to update mirror wiki repository '%s': %s", wikiPath, stderr) + log.Error(2, desc) + if err = CreateRepositoryNotice(desc); err != nil { + log.Error(2, "CreateRepositoryNotice: %v", err) + } + } + } + + return parseRemoteUpdateOutput(output), true +} + +func getMirrorByRepoID(e Engine, repoID int64) (*Mirror, error) { + m := &Mirror{RepoID: repoID} + has, err := e.Get(m) + if err != nil { + return nil, err + } else if !has { + return nil, errors.MirrorNotExist{repoID} + } + return m, nil +} + +// GetMirrorByRepoID returns mirror information of a repository. +func GetMirrorByRepoID(repoID int64) (*Mirror, error) { + return getMirrorByRepoID(x, repoID) +} + +func updateMirror(e Engine, m *Mirror) error { + _, err := e.ID(m.ID).AllCols().Update(m) + return err +} + +func UpdateMirror(m *Mirror) error { + return updateMirror(x, m) +} + +func DeleteMirrorByRepoID(repoID int64) error { + _, err := x.Delete(&Mirror{RepoID: repoID}) + return err +} + +// MirrorUpdate checks and updates mirror repositories. +func MirrorUpdate() { + if taskStatusTable.IsRunning(_MIRROR_UPDATE) { + return + } + taskStatusTable.Start(_MIRROR_UPDATE) + defer taskStatusTable.Stop(_MIRROR_UPDATE) + + log.Trace("Doing: MirrorUpdate") + + if err := x.Where("next_update_unix<=?", time.Now().Unix()).Iterate(new(Mirror), func(idx int, bean interface{}) error { + m := bean.(*Mirror) + if m.Repo == nil { + log.Error(2, "Disconnected mirror repository found: %d", m.ID) + return nil + } + + MirrorQueue.Add(m.RepoID) + return nil + }); err != nil { + log.Error(2, "MirrorUpdate: %v", err) + } +} + +// SyncMirrors checks and syncs mirrors. +// TODO: sync more mirrors at same time. +func SyncMirrors() { + // Start listening on new sync requests. + for repoID := range MirrorQueue.Queue() { + log.Trace("SyncMirrors [repo_id: %s]", repoID) + MirrorQueue.Remove(repoID) + + m, err := GetMirrorByRepoID(com.StrTo(repoID).MustInt64()) + if err != nil { + log.Error(2, "GetMirrorByRepoID [%d]: %v", m.RepoID, err) + continue + } + + results, ok := m.runSync() + if !ok { + continue + } + + m.ScheduleNextSync() + if err = UpdateMirror(m); err != nil { + log.Error(2, "UpdateMirror [%d]: %v", m.RepoID, err) + continue + } + + // TODO: + // - Create "Mirror Sync" webhook event + // - Create mirror sync (create, push and delete) events and trigger the "mirror sync" webhooks + + var gitRepo *git.Repository + if len(results) == 0 { + log.Trace("SyncMirrors [repo_id: %d]: no commits fetched", m.RepoID) + } else { + gitRepo, err = git.OpenRepository(m.Repo.RepoPath()) + if err != nil { + log.Error(2, "OpenRepository [%d]: %v", m.RepoID, err) + continue + } + } + + for _, result := range results { + // Discard GitHub pull requests, i.e. refs/pull/* + if strings.HasPrefix(result.refName, "refs/pull/") { + continue + } + + // Delete reference + if result.newCommitID == GIT_SHORT_EMPTY_SHA { + if err = MirrorSyncDeleteAction(m.Repo, result.refName); err != nil { + log.Error(2, "MirrorSyncDeleteAction [repo_id: %d]: %v", m.RepoID, err) + } + continue + } + + // New reference + isNewRef := false + if result.oldCommitID == GIT_SHORT_EMPTY_SHA { + if err = MirrorSyncCreateAction(m.Repo, result.refName); err != nil { + log.Error(2, "MirrorSyncCreateAction [repo_id: %d]: %v", m.RepoID, err) + continue + } + isNewRef = true + } + + // Push commits + var commits *list.List + var oldCommitID string + var newCommitID string + if !isNewRef { + oldCommitID, err = git.GetFullCommitID(gitRepo.Path, result.oldCommitID) + if err != nil { + log.Error(2, "GetFullCommitID [%d]: %v", m.RepoID, err) + continue + } + newCommitID, err = git.GetFullCommitID(gitRepo.Path, result.newCommitID) + if err != nil { + log.Error(2, "GetFullCommitID [%d]: %v", m.RepoID, err) + continue + } + commits, err = gitRepo.CommitsBetweenIDs(newCommitID, oldCommitID) + if err != nil { + log.Error(2, "CommitsBetweenIDs [repo_id: %d, new_commit_id: %s, old_commit_id: %s]: %v", m.RepoID, newCommitID, oldCommitID, err) + continue + } + } else { + refNewCommitID, err := gitRepo.GetBranchCommitID(result.refName) + if err != nil { + log.Error(2, "GetFullCommitID [%d]: %v", m.RepoID, err) + continue + } + if newCommit, err := gitRepo.GetCommit(refNewCommitID); err != nil { + log.Error(2, "GetCommit [repo_id: %d, commit_id: %s]: %v", m.RepoID, refNewCommitID, err) + continue + } else { + // TODO: Get the commits for the new ref until the closest ancestor branch like Github does + commits, err = newCommit.CommitsBeforeLimit(10) + if err != nil { + log.Error(2, "CommitsBeforeLimit [repo_id: %d, commit_id: %s]: %v", m.RepoID, refNewCommitID, err) + } + oldCommitID = git.EMPTY_SHA + newCommitID = refNewCommitID + } + } + if err = MirrorSyncPushAction(m.Repo, MirrorSyncPushActionOptions{ + RefName: result.refName, + OldCommitID: oldCommitID, + NewCommitID: newCommitID, + Commits: ListToPushCommits(commits), + }); err != nil { + log.Error(2, "MirrorSyncPushAction [repo_id: %d]: %v", m.RepoID, err) + continue + } + } + + if _, err = x.Exec("UPDATE mirror SET updated_unix = ? WHERE repo_id = ?", time.Now().Unix(), m.RepoID); err != nil { + log.Error(2, "Update 'mirror.updated_unix' [%d]: %v", m.RepoID, err) + continue + } + + // Get latest commit date and compare to current repository updated time, + // update if latest commit date is newer. + commitDate, err := git.GetLatestCommitDate(m.Repo.RepoPath(), "") + if err != nil { + log.Error(2, "GetLatestCommitDate [%d]: %v", m.RepoID, err) + continue + } else if commitDate.Before(m.Repo.Updated) { + continue + } + + if _, err = x.Exec("UPDATE repository SET updated_unix = ? WHERE id = ?", commitDate.Unix(), m.RepoID); err != nil { + log.Error(2, "Update 'repository.updated_unix' [%d]: %v", m.RepoID, err) + continue + } + } +} + +func InitSyncMirrors() { + go SyncMirrors() +} diff --git a/internal/db/mirror_test.go b/internal/db/mirror_test.go new file mode 100644 index 00000000..cc85546a --- /dev/null +++ b/internal/db/mirror_test.go @@ -0,0 +1,108 @@ +// Copyright 2017 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package db + +import ( + "testing" + + . "github.com/smartystreets/goconvey/convey" +) + +func Test_parseRemoteUpdateOutput(t *testing.T) { + Convey("Parse mirror remote update output", t, func() { + testCases := []struct { + output string + results []*mirrorSyncResult + }{ + { + ` +From https://try.gogs.io/unknwon/upsteam + * [new branch] develop -> develop + b0bb24f..1d85a4f master -> master + - [deleted] (none) -> bugfix +`, + []*mirrorSyncResult{ + {"develop", GIT_SHORT_EMPTY_SHA, ""}, + {"master", "b0bb24f", "1d85a4f"}, + {"bugfix", "", GIT_SHORT_EMPTY_SHA}, + }, + }, + } + + for _, tc := range testCases { + results := parseRemoteUpdateOutput(tc.output) + So(len(results), ShouldEqual, len(tc.results)) + + for i := range tc.results { + So(tc.results[i].refName, ShouldEqual, results[i].refName) + So(tc.results[i].oldCommitID, ShouldEqual, results[i].oldCommitID) + So(tc.results[i].newCommitID, ShouldEqual, results[i].newCommitID) + } + } + }) +} + +func Test_findPasswordInMirrorAddress(t *testing.T) { + Convey("Find password portion in mirror address", t, func() { + testCases := []struct { + addr string + start, end int + found bool + password string + }{ + {"http://localhost:3000/user/repo.git", -1, -1, false, ""}, + {"http://user@localhost:3000/user/repo.git", -1, -1, false, ""}, + {"http://user:@localhost:3000/user/repo.git", -1, -1, false, ""}, + {"http://user:password@localhost:3000/user/repo.git", 12, 20, true, "password"}, + {"http://username:my%3Asecure%3Bpassword@localhost:3000/user/repo.git", 16, 38, true, "my%3Asecure%3Bpassword"}, + {"http://username:my%40secure%23password@localhost:3000/user/repo.git", 16, 38, true, "my%40secure%23password"}, + {"http://username:@@localhost:3000/user/repo.git", 16, 17, true, "@"}, + } + + for _, tc := range testCases { + start, end, found := findPasswordInMirrorAddress(tc.addr) + So(start, ShouldEqual, tc.start) + So(end, ShouldEqual, tc.end) + So(found, ShouldEqual, tc.found) + if found { + So(tc.addr[start:end], ShouldEqual, tc.password) + } + } + }) +} + +func Test_unescapeMirrorCredentials(t *testing.T) { + Convey("Escape credentials in mirror address", t, func() { + testCases := []string{ + "http://localhost:3000/user/repo.git", "http://localhost:3000/user/repo.git", + "http://user@localhost:3000/user/repo.git", "http://user@localhost:3000/user/repo.git", + "http://user:@localhost:3000/user/repo.git", "http://user:@localhost:3000/user/repo.git", + "http://user:password@localhost:3000/user/repo.git", "http://user:password@localhost:3000/user/repo.git", + "http://user:my%3Asecure%3Bpassword@localhost:3000/user/repo.git", "http://user:my:secure;password@localhost:3000/user/repo.git", + "http://user:my%40secure%23password@localhost:3000/user/repo.git", "http://user:my@secure#password@localhost:3000/user/repo.git", + } + + for i := 0; i < len(testCases); i += 2 { + So(unescapeMirrorCredentials(testCases[i]), ShouldEqual, testCases[i+1]) + } + }) +} + +func Test_escapeMirrorCredentials(t *testing.T) { + Convey("Escape credentials in mirror address", t, func() { + testCases := []string{ + "http://localhost:3000/user/repo.git", "http://localhost:3000/user/repo.git", + "http://user@localhost:3000/user/repo.git", "http://user@localhost:3000/user/repo.git", + "http://user:@localhost:3000/user/repo.git", "http://user:@localhost:3000/user/repo.git", + "http://user:password@localhost:3000/user/repo.git", "http://user:password@localhost:3000/user/repo.git", + "http://user:my:secure;password@localhost:3000/user/repo.git", "http://user:my%3Asecure%3Bpassword@localhost:3000/user/repo.git", + "http://user:my@secure#password@localhost:3000/user/repo.git", "http://user:my%40secure%23password@localhost:3000/user/repo.git", + } + + for i := 0; i < len(testCases); i += 2 { + So(escapeMirrorCredentials(testCases[i]), ShouldEqual, testCases[i+1]) + } + }) +} diff --git a/internal/db/models.go b/internal/db/models.go new file mode 100644 index 00000000..75aeaf3a --- /dev/null +++ b/internal/db/models.go @@ -0,0 +1,401 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package db + +import ( + "bufio" + "database/sql" + "errors" + "fmt" + "net/url" + "os" + "path" + "strings" + "time" + + _ "github.com/denisenkom/go-mssqldb" + _ "github.com/go-sql-driver/mysql" + "github.com/json-iterator/go" + _ "github.com/lib/pq" + "github.com/unknwon/com" + log "gopkg.in/clog.v1" + "xorm.io/core" + "xorm.io/xorm" + + "gogs.io/gogs/internal/db/migrations" + "gogs.io/gogs/internal/setting" +) + +// Engine represents a XORM engine or session. +type Engine interface { + Delete(interface{}) (int64, error) + Exec(...interface{}) (sql.Result, error) + Find(interface{}, ...interface{}) error + Get(interface{}) (bool, error) + ID(interface{}) *xorm.Session + In(string, ...interface{}) *xorm.Session + Insert(...interface{}) (int64, error) + InsertOne(interface{}) (int64, error) + Iterate(interface{}, xorm.IterFunc) error + Sql(string, ...interface{}) *xorm.Session + Table(interface{}) *xorm.Session + Where(interface{}, ...interface{}) *xorm.Session +} + +var ( + x *xorm.Engine + tables []interface{} + HasEngine bool + + DbCfg struct { + Type, Host, Name, User, Passwd, Path, SSLMode string + } + + EnableSQLite3 bool +) + +func init() { + tables = append(tables, + new(User), new(PublicKey), new(AccessToken), new(TwoFactor), new(TwoFactorRecoveryCode), + new(Repository), new(DeployKey), new(Collaboration), new(Access), new(Upload), + new(Watch), new(Star), new(Follow), new(Action), + new(Issue), new(PullRequest), new(Comment), new(Attachment), new(IssueUser), + new(Label), new(IssueLabel), new(Milestone), + new(Mirror), new(Release), new(LoginSource), new(Webhook), new(HookTask), + new(ProtectBranch), new(ProtectBranchWhitelist), + new(Team), new(OrgUser), new(TeamUser), new(TeamRepo), + new(Notice), new(EmailAddress)) + + gonicNames := []string{"SSL"} + for _, name := range gonicNames { + core.LintGonicMapper[name] = true + } +} + +func LoadConfigs() { + sec := setting.Cfg.Section("database") + DbCfg.Type = sec.Key("DB_TYPE").String() + switch DbCfg.Type { + case "sqlite3": + setting.UseSQLite3 = true + case "mysql": + setting.UseMySQL = true + case "postgres": + setting.UsePostgreSQL = true + case "mssql": + setting.UseMSSQL = true + } + DbCfg.Host = sec.Key("HOST").String() + DbCfg.Name = sec.Key("NAME").String() + DbCfg.User = sec.Key("USER").String() + if len(DbCfg.Passwd) == 0 { + DbCfg.Passwd = sec.Key("PASSWD").String() + } + DbCfg.SSLMode = sec.Key("SSL_MODE").String() + DbCfg.Path = sec.Key("PATH").MustString("data/gogs.db") +} + +// parsePostgreSQLHostPort parses given input in various forms defined in +// https://www.postgresql.org/docs/current/static/libpq-connect.html#LIBPQ-CONNSTRING +// and returns proper host and port number. +func parsePostgreSQLHostPort(info string) (string, string) { + host, port := "127.0.0.1", "5432" + if strings.Contains(info, ":") && !strings.HasSuffix(info, "]") { + idx := strings.LastIndex(info, ":") + host = info[:idx] + port = info[idx+1:] + } else if len(info) > 0 { + host = info + } + return host, port +} + +func parseMSSQLHostPort(info string) (string, string) { + host, port := "127.0.0.1", "1433" + if strings.Contains(info, ":") { + host = strings.Split(info, ":")[0] + port = strings.Split(info, ":")[1] + } else if strings.Contains(info, ",") { + host = strings.Split(info, ",")[0] + port = strings.TrimSpace(strings.Split(info, ",")[1]) + } else if len(info) > 0 { + host = info + } + return host, port +} + +func getEngine() (*xorm.Engine, error) { + connStr := "" + var Param string = "?" + if strings.Contains(DbCfg.Name, Param) { + Param = "&" + } + switch DbCfg.Type { + case "mysql": + if DbCfg.Host[0] == '/' { // looks like a unix socket + connStr = fmt.Sprintf("%s:%s@unix(%s)/%s%scharset=utf8mb4&parseTime=true", + DbCfg.User, DbCfg.Passwd, DbCfg.Host, DbCfg.Name, Param) + } else { + connStr = fmt.Sprintf("%s:%s@tcp(%s)/%s%scharset=utf8mb4&parseTime=true", + DbCfg.User, DbCfg.Passwd, DbCfg.Host, DbCfg.Name, Param) + } + var engineParams = map[string]string{"rowFormat": "DYNAMIC"} + return xorm.NewEngineWithParams(DbCfg.Type, connStr, engineParams) + case "postgres": + host, port := parsePostgreSQLHostPort(DbCfg.Host) + if host[0] == '/' { // looks like a unix socket + connStr = fmt.Sprintf("postgres://%s:%s@:%s/%s%ssslmode=%s&host=%s", + url.QueryEscape(DbCfg.User), url.QueryEscape(DbCfg.Passwd), port, DbCfg.Name, Param, DbCfg.SSLMode, host) + } else { + connStr = fmt.Sprintf("postgres://%s:%s@%s:%s/%s%ssslmode=%s", + url.QueryEscape(DbCfg.User), url.QueryEscape(DbCfg.Passwd), host, port, DbCfg.Name, Param, DbCfg.SSLMode) + } + case "mssql": + host, port := parseMSSQLHostPort(DbCfg.Host) + connStr = fmt.Sprintf("server=%s; port=%s; database=%s; user id=%s; password=%s;", host, port, DbCfg.Name, DbCfg.User, DbCfg.Passwd) + case "sqlite3": + if !EnableSQLite3 { + return nil, errors.New("this binary version does not build support for SQLite3") + } + if err := os.MkdirAll(path.Dir(DbCfg.Path), os.ModePerm); err != nil { + return nil, fmt.Errorf("create directories: %v", err) + } + connStr = "file:" + DbCfg.Path + "?cache=shared&mode=rwc" + default: + return nil, fmt.Errorf("unknown database type: %s", DbCfg.Type) + } + return xorm.NewEngine(DbCfg.Type, connStr) +} + +func NewTestEngine(x *xorm.Engine) (err error) { + x, err = getEngine() + if err != nil { + return fmt.Errorf("connect to database: %v", err) + } + + x.SetMapper(core.GonicMapper{}) + return x.StoreEngine("InnoDB").Sync2(tables...) +} + +func SetEngine() (err error) { + x, err = getEngine() + if err != nil { + return fmt.Errorf("connect to database: %v", err) + } + + x.SetMapper(core.GonicMapper{}) + + // WARNING: for serv command, MUST remove the output to os.stdout, + // so use log file to instead print to stdout. + sec := setting.Cfg.Section("log.xorm") + logger, err := log.NewFileWriter(path.Join(setting.LogRootPath, "xorm.log"), + log.FileRotationConfig{ + Rotate: sec.Key("ROTATE").MustBool(true), + Daily: sec.Key("ROTATE_DAILY").MustBool(true), + MaxSize: sec.Key("MAX_SIZE").MustInt64(100) * 1024 * 1024, + MaxDays: sec.Key("MAX_DAYS").MustInt64(3), + }) + if err != nil { + return fmt.Errorf("create 'xorm.log': %v", err) + } + + // To prevent mystery "MySQL: invalid connection" error, + // see https://gogs.io/gogs/issues/5532. + x.SetMaxIdleConns(0) + x.SetConnMaxLifetime(time.Second) + + if setting.ProdMode { + x.SetLogger(xorm.NewSimpleLogger3(logger, xorm.DEFAULT_LOG_PREFIX, xorm.DEFAULT_LOG_FLAG, core.LOG_WARNING)) + } else { + x.SetLogger(xorm.NewSimpleLogger(logger)) + } + x.ShowSQL(true) + return nil +} + +func NewEngine() (err error) { + if err = SetEngine(); err != nil { + return err + } + + if err = migrations.Migrate(x); err != nil { + return fmt.Errorf("migrate: %v", err) + } + + if err = x.StoreEngine("InnoDB").Sync2(tables...); err != nil { + return fmt.Errorf("sync structs to database tables: %v\n", err) + } + + return nil +} + +type Statistic struct { + Counter struct { + User, Org, PublicKey, + Repo, Watch, Star, Action, Access, + Issue, Comment, Oauth, Follow, + Mirror, Release, LoginSource, Webhook, + Milestone, Label, HookTask, + Team, UpdateTask, Attachment int64 + } +} + +func GetStatistic() (stats Statistic) { + stats.Counter.User = CountUsers() + stats.Counter.Org = CountOrganizations() + stats.Counter.PublicKey, _ = x.Count(new(PublicKey)) + stats.Counter.Repo = CountRepositories(true) + stats.Counter.Watch, _ = x.Count(new(Watch)) + stats.Counter.Star, _ = x.Count(new(Star)) + stats.Counter.Action, _ = x.Count(new(Action)) + stats.Counter.Access, _ = x.Count(new(Access)) + stats.Counter.Issue, _ = x.Count(new(Issue)) + stats.Counter.Comment, _ = x.Count(new(Comment)) + stats.Counter.Oauth = 0 + stats.Counter.Follow, _ = x.Count(new(Follow)) + stats.Counter.Mirror, _ = x.Count(new(Mirror)) + stats.Counter.Release, _ = x.Count(new(Release)) + stats.Counter.LoginSource = CountLoginSources() + stats.Counter.Webhook, _ = x.Count(new(Webhook)) + stats.Counter.Milestone, _ = x.Count(new(Milestone)) + stats.Counter.Label, _ = x.Count(new(Label)) + stats.Counter.HookTask, _ = x.Count(new(HookTask)) + stats.Counter.Team, _ = x.Count(new(Team)) + stats.Counter.Attachment, _ = x.Count(new(Attachment)) + return +} + +func Ping() error { + return x.Ping() +} + +// The version table. Should have only one row with id==1 +type Version struct { + ID int64 + Version int64 +} + +// DumpDatabase dumps all data from database to file system in JSON format. +func DumpDatabase(dirPath string) (err error) { + os.MkdirAll(dirPath, os.ModePerm) + // Purposely create a local variable to not modify global variable + tables := append(tables, new(Version)) + for _, table := range tables { + tableName := strings.TrimPrefix(fmt.Sprintf("%T", table), "*db.") + tableFile := path.Join(dirPath, tableName+".json") + f, err := os.Create(tableFile) + if err != nil { + return fmt.Errorf("create JSON file: %v", err) + } + + if err = x.Asc("id").Iterate(table, func(idx int, bean interface{}) (err error) { + return jsoniter.NewEncoder(f).Encode(bean) + }); err != nil { + f.Close() + return fmt.Errorf("dump table '%s': %v", tableName, err) + } + f.Close() + } + return nil +} + +// ImportDatabase imports data from backup archive. +func ImportDatabase(dirPath string, verbose bool) (err error) { + snakeMapper := core.SnakeMapper{} + + skipInsertProcessors := map[string]bool{ + "mirror": true, + "milestone": true, + } + + // Purposely create a local variable to not modify global variable + tables := append(tables, new(Version)) + for _, table := range tables { + tableName := strings.TrimPrefix(fmt.Sprintf("%T", table), "*db.") + tableFile := path.Join(dirPath, tableName+".json") + if !com.IsExist(tableFile) { + continue + } + + if verbose { + log.Trace("Importing table '%s'...", tableName) + } + + if err = x.DropTables(table); err != nil { + return fmt.Errorf("drop table '%s': %v", tableName, err) + } else if err = x.Sync2(table); err != nil { + return fmt.Errorf("sync table '%s': %v", tableName, err) + } + + f, err := os.Open(tableFile) + if err != nil { + return fmt.Errorf("open JSON file: %v", err) + } + rawTableName := x.TableName(table) + _, isInsertProcessor := table.(xorm.BeforeInsertProcessor) + scanner := bufio.NewScanner(f) + for scanner.Scan() { + switch bean := table.(type) { + case *LoginSource: + meta := make(map[string]interface{}) + if err = jsoniter.Unmarshal(scanner.Bytes(), &meta); err != nil { + return fmt.Errorf("unmarshal to map: %v", err) + } + + tp := LoginType(com.StrTo(com.ToStr(meta["Type"])).MustInt64()) + switch tp { + case LOGIN_LDAP, LOGIN_DLDAP: + bean.Cfg = new(LDAPConfig) + case LOGIN_SMTP: + bean.Cfg = new(SMTPConfig) + case LOGIN_PAM: + bean.Cfg = new(PAMConfig) + case LOGIN_GITHUB: + bean.Cfg = new(GitHubConfig) + default: + return fmt.Errorf("unrecognized login source type:: %v", tp) + } + table = bean + } + + if err = jsoniter.Unmarshal(scanner.Bytes(), table); err != nil { + return fmt.Errorf("unmarshal to struct: %v", err) + } + + if _, err = x.Insert(table); err != nil { + return fmt.Errorf("insert strcut: %v", err) + } + + meta := make(map[string]interface{}) + if err = jsoniter.Unmarshal(scanner.Bytes(), &meta); err != nil { + log.Error(2, "Failed to unmarshal to map: %v", err) + } + + // Reset created_unix back to the date save in archive because Insert method updates its value + if isInsertProcessor && !skipInsertProcessors[rawTableName] { + if _, err = x.Exec("UPDATE "+rawTableName+" SET created_unix=? WHERE id=?", meta["CreatedUnix"], meta["ID"]); err != nil { + log.Error(2, "Failed to reset 'created_unix': %v", err) + } + } + + switch rawTableName { + case "milestone": + if _, err = x.Exec("UPDATE "+rawTableName+" SET deadline_unix=?, closed_date_unix=? WHERE id=?", meta["DeadlineUnix"], meta["ClosedDateUnix"], meta["ID"]); err != nil { + log.Error(2, "Failed to reset 'milestone.deadline_unix', 'milestone.closed_date_unix': %v", err) + } + } + } + + // PostgreSQL needs manually reset table sequence for auto increment keys + if setting.UsePostgreSQL { + rawTableName := snakeMapper.Obj2Table(tableName) + seqName := rawTableName + "_id_seq" + if _, err = x.Exec(fmt.Sprintf(`SELECT setval('%s', COALESCE((SELECT MAX(id)+1 FROM "%s"), 1), false);`, seqName, rawTableName)); err != nil { + return fmt.Errorf("reset table '%s' sequence: %v", rawTableName, err) + } + } + } + return nil +} diff --git a/internal/db/models_sqlite.go b/internal/db/models_sqlite.go new file mode 100644 index 00000000..c462cc5d --- /dev/null +++ b/internal/db/models_sqlite.go @@ -0,0 +1,15 @@ +// +build sqlite + +// Copyright 2014 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package db + +import ( + _ "github.com/mattn/go-sqlite3" +) + +func init() { + EnableSQLite3 = true +} diff --git a/internal/db/models_test.go b/internal/db/models_test.go new file mode 100644 index 00000000..53f8b4f0 --- /dev/null +++ b/internal/db/models_test.go @@ -0,0 +1,33 @@ +// Copyright 2016 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package db + +import ( + "testing" + + . "github.com/smartystreets/goconvey/convey" +) + +func Test_parsePostgreSQLHostPort(t *testing.T) { + testSuites := []struct { + input string + host, port string + }{ + {"127.0.0.1:1234", "127.0.0.1", "1234"}, + {"127.0.0.1", "127.0.0.1", "5432"}, + {"[::1]:1234", "[::1]", "1234"}, + {"[::1]", "[::1]", "5432"}, + {"/tmp/pg.sock:1234", "/tmp/pg.sock", "1234"}, + {"/tmp/pg.sock", "/tmp/pg.sock", "5432"}, + } + + Convey("Parse PostgreSQL host and port", t, func() { + for _, suite := range testSuites { + host, port := parsePostgreSQLHostPort(suite.input) + So(host, ShouldEqual, suite.host) + So(port, ShouldEqual, suite.port) + } + }) +} diff --git a/internal/db/org.go b/internal/db/org.go new file mode 100644 index 00000000..fb16c830 --- /dev/null +++ b/internal/db/org.go @@ -0,0 +1,563 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package db + +import ( + "errors" + "fmt" + "os" + "strings" + + "github.com/go-xorm/builder" + "xorm.io/xorm" +) + +var ( + ErrOrgNotExist = errors.New("Organization does not exist") +) + +// IsOwnedBy returns true if given user is in the owner team. +func (org *User) IsOwnedBy(userID int64) bool { + return IsOrganizationOwner(org.ID, userID) +} + +// IsOrgMember returns true if given user is member of organization. +func (org *User) IsOrgMember(uid int64) bool { + return org.IsOrganization() && IsOrganizationMember(org.ID, uid) +} + +func (org *User) getTeam(e Engine, name string) (*Team, error) { + return getTeamOfOrgByName(e, org.ID, name) +} + +// GetTeamOfOrgByName returns named team of organization. +func (org *User) GetTeam(name string) (*Team, error) { + return org.getTeam(x, name) +} + +func (org *User) getOwnerTeam(e Engine) (*Team, error) { + return org.getTeam(e, OWNER_TEAM) +} + +// GetOwnerTeam returns owner team of organization. +func (org *User) GetOwnerTeam() (*Team, error) { + return org.getOwnerTeam(x) +} + +func (org *User) getTeams(e Engine) (err error) { + org.Teams, err = getTeamsByOrgID(e, org.ID) + return err +} + +// GetTeams returns all teams that belong to organization. +func (org *User) GetTeams() error { + return org.getTeams(x) +} + +// TeamsHaveAccessToRepo returns all teamsthat have given access level to the repository. +func (org *User) TeamsHaveAccessToRepo(repoID int64, mode AccessMode) ([]*Team, error) { + return GetTeamsHaveAccessToRepo(org.ID, repoID, mode) +} + +// GetMembers returns all members of organization. +func (org *User) GetMembers() error { + ous, err := GetOrgUsersByOrgID(org.ID) + if err != nil { + return err + } + + org.Members = make([]*User, len(ous)) + for i, ou := range ous { + org.Members[i], err = GetUserByID(ou.Uid) + if err != nil { + return err + } + } + return nil +} + +// AddMember adds new member to organization. +func (org *User) AddMember(uid int64) error { + return AddOrgUser(org.ID, uid) +} + +// RemoveMember removes member from organization. +func (org *User) RemoveMember(uid int64) error { + return RemoveOrgUser(org.ID, uid) +} + +func (org *User) removeOrgRepo(e Engine, repoID int64) error { + return removeOrgRepo(e, org.ID, repoID) +} + +// RemoveOrgRepo removes all team-repository relations of organization. +func (org *User) RemoveOrgRepo(repoID int64) error { + return org.removeOrgRepo(x, repoID) +} + +// CreateOrganization creates record of a new organization. +func CreateOrganization(org, owner *User) (err error) { + if err = IsUsableUsername(org.Name); err != nil { + return err + } + + isExist, err := IsUserExist(0, org.Name) + if err != nil { + return err + } else if isExist { + return ErrUserAlreadyExist{org.Name} + } + + org.LowerName = strings.ToLower(org.Name) + if org.Rands, err = GetUserSalt(); err != nil { + return err + } + if org.Salt, err = GetUserSalt(); err != nil { + return err + } + org.UseCustomAvatar = true + org.MaxRepoCreation = -1 + org.NumTeams = 1 + org.NumMembers = 1 + + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return err + } + + if _, err = sess.Insert(org); err != nil { + return fmt.Errorf("insert organization: %v", err) + } + org.GenerateRandomAvatar() + + // Add initial creator to organization and owner team. + if _, err = sess.Insert(&OrgUser{ + Uid: owner.ID, + OrgID: org.ID, + IsOwner: true, + NumTeams: 1, + }); err != nil { + return fmt.Errorf("insert org-user relation: %v", err) + } + + // Create default owner team. + t := &Team{ + OrgID: org.ID, + LowerName: strings.ToLower(OWNER_TEAM), + Name: OWNER_TEAM, + Authorize: ACCESS_MODE_OWNER, + NumMembers: 1, + } + if _, err = sess.Insert(t); err != nil { + return fmt.Errorf("insert owner team: %v", err) + } + + if _, err = sess.Insert(&TeamUser{ + UID: owner.ID, + OrgID: org.ID, + TeamID: t.ID, + }); err != nil { + return fmt.Errorf("insert team-user relation: %v", err) + } + + if err = os.MkdirAll(UserPath(org.Name), os.ModePerm); err != nil { + return fmt.Errorf("create directory: %v", err) + } + + return sess.Commit() +} + +// GetOrgByName returns organization by given name. +func GetOrgByName(name string) (*User, error) { + if len(name) == 0 { + return nil, ErrOrgNotExist + } + u := &User{ + LowerName: strings.ToLower(name), + Type: USER_TYPE_ORGANIZATION, + } + has, err := x.Get(u) + if err != nil { + return nil, err + } else if !has { + return nil, ErrOrgNotExist + } + return u, nil +} + +// CountOrganizations returns number of organizations. +func CountOrganizations() int64 { + count, _ := x.Where("type=1").Count(new(User)) + return count +} + +// Organizations returns number of organizations in given page. +func Organizations(page, pageSize int) ([]*User, error) { + orgs := make([]*User, 0, pageSize) + return orgs, x.Limit(pageSize, (page-1)*pageSize).Where("type=1").Asc("id").Find(&orgs) +} + +// DeleteOrganization completely and permanently deletes everything of organization. +func DeleteOrganization(org *User) (err error) { + if err := DeleteUser(org); err != nil { + return err + } + + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return err + } + + if err = deleteBeans(sess, + &Team{OrgID: org.ID}, + &OrgUser{OrgID: org.ID}, + &TeamUser{OrgID: org.ID}, + ); err != nil { + return fmt.Errorf("deleteBeans: %v", err) + } + + if err = deleteUser(sess, org); err != nil { + return fmt.Errorf("deleteUser: %v", err) + } + + return sess.Commit() +} + +// ________ ____ ___ +// \_____ \_______ ____ | | \______ ___________ +// / | \_ __ \/ ___\| | / ___// __ \_ __ \ +// / | \ | \/ /_/ > | /\___ \\ ___/| | \/ +// \_______ /__| \___ /|______//____ >\___ >__| +// \/ /_____/ \/ \/ + +// OrgUser represents an organization-user relation. +type OrgUser struct { + ID int64 + Uid int64 `xorm:"INDEX UNIQUE(s)"` + OrgID int64 `xorm:"INDEX UNIQUE(s)"` + IsPublic bool + IsOwner bool + NumTeams int +} + +// IsOrganizationOwner returns true if given user is in the owner team. +func IsOrganizationOwner(orgID, userID int64) bool { + has, _ := x.Where("is_owner = ?", true).And("uid = ?", userID).And("org_id = ?", orgID).Get(new(OrgUser)) + return has +} + +// IsOrganizationMember returns true if given user is member of organization. +func IsOrganizationMember(orgId, uid int64) bool { + has, _ := x.Where("uid=?", uid).And("org_id=?", orgId).Get(new(OrgUser)) + return has +} + +// IsPublicMembership returns true if given user public his/her membership. +func IsPublicMembership(orgId, uid int64) bool { + has, _ := x.Where("uid=?", uid).And("org_id=?", orgId).And("is_public=?", true).Get(new(OrgUser)) + return has +} + +func getOrgsByUserID(sess *xorm.Session, userID int64, showAll bool) ([]*User, error) { + orgs := make([]*User, 0, 10) + if !showAll { + sess.And("`org_user`.is_public=?", true) + } + return orgs, sess.And("`org_user`.uid=?", userID). + Join("INNER", "`org_user`", "`org_user`.org_id=`user`.id").Find(&orgs) +} + +// GetOrgsByUserID returns a list of organizations that the given user ID +// has joined. +func GetOrgsByUserID(userID int64, showAll bool) ([]*User, error) { + return getOrgsByUserID(x.NewSession(), userID, showAll) +} + +// GetOrgsByUserIDDesc returns a list of organizations that the given user ID +// has joined, ordered descending by the given condition. +func GetOrgsByUserIDDesc(userID int64, desc string, showAll bool) ([]*User, error) { + return getOrgsByUserID(x.NewSession().Desc(desc), userID, showAll) +} + +func getOwnedOrgsByUserID(sess *xorm.Session, userID int64) ([]*User, error) { + orgs := make([]*User, 0, 10) + return orgs, sess.Where("`org_user`.uid=?", userID).And("`org_user`.is_owner=?", true). + Join("INNER", "`org_user`", "`org_user`.org_id=`user`.id").Find(&orgs) +} + +// GetOwnedOrgsByUserID returns a list of organizations are owned by given user ID. +func GetOwnedOrgsByUserID(userID int64) ([]*User, error) { + sess := x.NewSession() + return getOwnedOrgsByUserID(sess, userID) +} + +// GetOwnedOrganizationsByUserIDDesc returns a list of organizations are owned by +// given user ID, ordered descending by the given condition. +func GetOwnedOrgsByUserIDDesc(userID int64, desc string) ([]*User, error) { + sess := x.NewSession() + return getOwnedOrgsByUserID(sess.Desc(desc), userID) +} + +// GetOrgIDsByUserID returns a list of organization IDs that user belongs to. +// The showPrivate indicates whether to include private memberships. +func GetOrgIDsByUserID(userID int64, showPrivate bool) ([]int64, error) { + orgIDs := make([]int64, 0, 5) + sess := x.Table("org_user").Where("uid = ?", userID) + if !showPrivate { + sess.And("is_public = ?", true) + } + return orgIDs, sess.Distinct("org_id").Find(&orgIDs) +} + +func getOrgUsersByOrgID(e Engine, orgID int64) ([]*OrgUser, error) { + orgUsers := make([]*OrgUser, 0, 10) + return orgUsers, e.Where("org_id=?", orgID).Find(&orgUsers) +} + +// GetOrgUsersByOrgID returns all organization-user relations by organization ID. +func GetOrgUsersByOrgID(orgID int64) ([]*OrgUser, error) { + return getOrgUsersByOrgID(x, orgID) +} + +// ChangeOrgUserStatus changes public or private membership status. +func ChangeOrgUserStatus(orgID, uid int64, public bool) error { + ou := new(OrgUser) + has, err := x.Where("uid=?", uid).And("org_id=?", orgID).Get(ou) + if err != nil { + return err + } else if !has { + return nil + } + + ou.IsPublic = public + _, err = x.Id(ou.ID).AllCols().Update(ou) + return err +} + +// AddOrgUser adds new user to given organization. +func AddOrgUser(orgID, uid int64) error { + if IsOrganizationMember(orgID, uid) { + return nil + } + + sess := x.NewSession() + defer sess.Close() + if err := sess.Begin(); err != nil { + return err + } + + ou := &OrgUser{ + Uid: uid, + OrgID: orgID, + } + + if _, err := sess.Insert(ou); err != nil { + sess.Rollback() + return err + } else if _, err = sess.Exec("UPDATE `user` SET num_members = num_members + 1 WHERE id = ?", orgID); err != nil { + sess.Rollback() + return err + } + + return sess.Commit() +} + +// RemoveOrgUser removes user from given organization. +func RemoveOrgUser(orgID, userID int64) error { + ou := new(OrgUser) + + has, err := x.Where("uid=?", userID).And("org_id=?", orgID).Get(ou) + if err != nil { + return fmt.Errorf("get org-user: %v", err) + } else if !has { + return nil + } + + user, err := GetUserByID(userID) + if err != nil { + return fmt.Errorf("GetUserByID [%d]: %v", userID, err) + } + org, err := GetUserByID(orgID) + if err != nil { + return fmt.Errorf("GetUserByID [%d]: %v", orgID, err) + } + + // FIXME: only need to get IDs here, not all fields of repository. + repos, _, err := org.GetUserRepositories(user.ID, 1, org.NumRepos) + if err != nil { + return fmt.Errorf("GetUserRepositories [%d]: %v", user.ID, err) + } + + // Check if the user to delete is the last member in owner team. + if IsOrganizationOwner(orgID, userID) { + t, err := org.GetOwnerTeam() + if err != nil { + return err + } + if t.NumMembers == 1 { + return ErrLastOrgOwner{UID: userID} + } + } + + sess := x.NewSession() + defer sess.Close() + if err := sess.Begin(); err != nil { + return err + } + + if _, err := sess.ID(ou.ID).Delete(ou); err != nil { + return err + } else if _, err = sess.Exec("UPDATE `user` SET num_members=num_members-1 WHERE id=?", orgID); err != nil { + return err + } + + // Delete all repository accesses and unwatch them. + repoIDs := make([]int64, len(repos)) + for i := range repos { + repoIDs = append(repoIDs, repos[i].ID) + if err = watchRepo(sess, user.ID, repos[i].ID, false); err != nil { + return err + } + } + + if len(repoIDs) > 0 { + if _, err = sess.Where("user_id = ?", user.ID).In("repo_id", repoIDs).Delete(new(Access)); err != nil { + return err + } + } + + // Delete member in his/her teams. + teams, err := getUserTeams(sess, org.ID, user.ID) + if err != nil { + return err + } + for _, t := range teams { + if err = removeTeamMember(sess, org.ID, t.ID, user.ID); err != nil { + return err + } + } + + return sess.Commit() +} + +func removeOrgRepo(e Engine, orgID, repoID int64) error { + _, err := e.Delete(&TeamRepo{ + OrgID: orgID, + RepoID: repoID, + }) + return err +} + +// RemoveOrgRepo removes all team-repository relations of given organization. +func RemoveOrgRepo(orgID, repoID int64) error { + return removeOrgRepo(x, orgID, repoID) +} + +func (org *User) getUserTeams(e Engine, userID int64, cols ...string) ([]*Team, error) { + teams := make([]*Team, 0, org.NumTeams) + return teams, e.Where("team_user.org_id = ?", org.ID). + And("team_user.uid = ?", userID). + Join("INNER", "team_user", "team_user.team_id = team.id"). + Cols(cols...).Find(&teams) +} + +// GetUserTeamIDs returns of all team IDs of the organization that user is memeber of. +func (org *User) GetUserTeamIDs(userID int64) ([]int64, error) { + teams, err := org.getUserTeams(x, userID, "team.id") + if err != nil { + return nil, fmt.Errorf("getUserTeams [%d]: %v", userID, err) + } + + teamIDs := make([]int64, len(teams)) + for i := range teams { + teamIDs[i] = teams[i].ID + } + return teamIDs, nil +} + +// GetTeams returns all teams that belong to organization, +// and that the user has joined. +func (org *User) GetUserTeams(userID int64) ([]*Team, error) { + return org.getUserTeams(x, userID) +} + +// GetUserRepositories returns a range of repositories in organization which the user has access to, +// and total number of records based on given condition. +func (org *User) GetUserRepositories(userID int64, page, pageSize int) ([]*Repository, int64, error) { + teamIDs, err := org.GetUserTeamIDs(userID) + if err != nil { + return nil, 0, fmt.Errorf("GetUserTeamIDs: %v", err) + } + if len(teamIDs) == 0 { + // user has no team but "IN ()" is invalid SQL + teamIDs = []int64{-1} // there is no team with id=-1 + } + + var teamRepoIDs []int64 + if err = x.Table("team_repo").In("team_id", teamIDs).Distinct("repo_id").Find(&teamRepoIDs); err != nil { + return nil, 0, fmt.Errorf("get team repository IDs: %v", err) + } + if len(teamRepoIDs) == 0 { + // team has no repo but "IN ()" is invalid SQL + teamRepoIDs = []int64{-1} // there is no repo with id=-1 + } + + if page <= 0 { + page = 1 + } + repos := make([]*Repository, 0, pageSize) + if err = x.Where("owner_id = ?", org.ID). + And("is_private = ?", false). + Or(builder.In("id", teamRepoIDs)). + Desc("updated_unix"). + Limit(pageSize, (page-1)*pageSize). + Find(&repos); err != nil { + return nil, 0, fmt.Errorf("get user repositories: %v", err) + } + + repoCount, err := x.Where("owner_id = ?", org.ID). + And("is_private = ?", false). + Or(builder.In("id", teamRepoIDs)). + Count(new(Repository)) + if err != nil { + return nil, 0, fmt.Errorf("count user repositories: %v", err) + } + + return repos, repoCount, nil +} + +// GetUserMirrorRepositories returns mirror repositories of the organization which the user has access to. +func (org *User) GetUserMirrorRepositories(userID int64) ([]*Repository, error) { + teamIDs, err := org.GetUserTeamIDs(userID) + if err != nil { + return nil, fmt.Errorf("GetUserTeamIDs: %v", err) + } + if len(teamIDs) == 0 { + teamIDs = []int64{-1} + } + + var teamRepoIDs []int64 + err = x.Table("team_repo").In("team_id", teamIDs).Distinct("repo_id").Find(&teamRepoIDs) + if err != nil { + return nil, fmt.Errorf("get team repository ids: %v", err) + } + if len(teamRepoIDs) == 0 { + // team has no repo but "IN ()" is invalid SQL + teamRepoIDs = []int64{-1} // there is no repo with id=-1 + } + + repos := make([]*Repository, 0, 10) + if err = x.Where("owner_id = ?", org.ID). + And("is_private = ?", false). + Or(builder.In("id", teamRepoIDs)). + And("is_mirror = ?", true). // Don't move up because it's an independent condition + Desc("updated_unix"). + Find(&repos); err != nil { + return nil, fmt.Errorf("get user repositories: %v", err) + } + return repos, nil +} diff --git a/internal/db/org_team.go b/internal/db/org_team.go new file mode 100644 index 00000000..7021e42d --- /dev/null +++ b/internal/db/org_team.go @@ -0,0 +1,666 @@ +// Copyright 2016 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package db + +import ( + "fmt" + "strings" + + "xorm.io/xorm" + + "gogs.io/gogs/internal/db/errors" +) + +const OWNER_TEAM = "Owners" + +// Team represents a organization team. +type Team struct { + ID int64 + OrgID int64 `xorm:"INDEX"` + LowerName string + Name string + Description string + Authorize AccessMode + Repos []*Repository `xorm:"-" json:"-"` + Members []*User `xorm:"-" json:"-"` + NumRepos int + NumMembers int +} + +func (t *Team) AfterSet(colName string, _ xorm.Cell) { + switch colName { + case "num_repos": + // LEGACY [1.0]: this is backward compatibility bug fix for https://gogs.io/gogs/issues/3671 + if t.NumRepos < 0 { + t.NumRepos = 0 + } + } +} + +// IsOwnerTeam returns true if team is owner team. +func (t *Team) IsOwnerTeam() bool { + return t.Name == OWNER_TEAM +} + +// HasWriteAccess returns true if team has at least write level access mode. +func (t *Team) HasWriteAccess() bool { + return t.Authorize >= ACCESS_MODE_WRITE +} + +// IsTeamMember returns true if given user is a member of team. +func (t *Team) IsMember(userID int64) bool { + return IsTeamMember(t.OrgID, t.ID, userID) +} + +func (t *Team) getRepositories(e Engine) (err error) { + teamRepos := make([]*TeamRepo, 0, t.NumRepos) + if err = x.Where("team_id=?", t.ID).Find(&teamRepos); err != nil { + return fmt.Errorf("get team-repos: %v", err) + } + + t.Repos = make([]*Repository, 0, len(teamRepos)) + for i := range teamRepos { + repo, err := getRepositoryByID(e, teamRepos[i].RepoID) + if err != nil { + return fmt.Errorf("getRepositoryById(%d): %v", teamRepos[i].RepoID, err) + } + t.Repos = append(t.Repos, repo) + } + return nil +} + +// GetRepositories returns all repositories in team of organization. +func (t *Team) GetRepositories() error { + return t.getRepositories(x) +} + +func (t *Team) getMembers(e Engine) (err error) { + t.Members, err = getTeamMembers(e, t.ID) + return err +} + +// GetMembers returns all members in team of organization. +func (t *Team) GetMembers() (err error) { + return t.getMembers(x) +} + +// AddMember adds new membership of the team to the organization, +// the user will have membership to the organization automatically when needed. +func (t *Team) AddMember(uid int64) error { + return AddTeamMember(t.OrgID, t.ID, uid) +} + +// RemoveMember removes member from team of organization. +func (t *Team) RemoveMember(uid int64) error { + return RemoveTeamMember(t.OrgID, t.ID, uid) +} + +func (t *Team) hasRepository(e Engine, repoID int64) bool { + return hasTeamRepo(e, t.OrgID, t.ID, repoID) +} + +// HasRepository returns true if given repository belong to team. +func (t *Team) HasRepository(repoID int64) bool { + return t.hasRepository(x, repoID) +} + +func (t *Team) addRepository(e Engine, repo *Repository) (err error) { + if err = addTeamRepo(e, t.OrgID, t.ID, repo.ID); err != nil { + return err + } + + t.NumRepos++ + if _, err = e.ID(t.ID).AllCols().Update(t); err != nil { + return fmt.Errorf("update team: %v", err) + } + + if err = repo.recalculateTeamAccesses(e, 0); err != nil { + return fmt.Errorf("recalculateAccesses: %v", err) + } + + if err = t.getMembers(e); err != nil { + return fmt.Errorf("getMembers: %v", err) + } + for _, u := range t.Members { + if err = watchRepo(e, u.ID, repo.ID, true); err != nil { + return fmt.Errorf("watchRepo: %v", err) + } + } + return nil +} + +// AddRepository adds new repository to team of organization. +func (t *Team) AddRepository(repo *Repository) (err error) { + if repo.OwnerID != t.OrgID { + return errors.New("Repository does not belong to organization") + } else if t.HasRepository(repo.ID) { + return nil + } + + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return err + } + + if err = t.addRepository(sess, repo); err != nil { + return err + } + + return sess.Commit() +} + +func (t *Team) removeRepository(e Engine, repo *Repository, recalculate bool) (err error) { + if err = removeTeamRepo(e, t.ID, repo.ID); err != nil { + return err + } + + t.NumRepos-- + if _, err = e.ID(t.ID).AllCols().Update(t); err != nil { + return err + } + + // Don't need to recalculate when delete a repository from organization. + if recalculate { + if err = repo.recalculateTeamAccesses(e, t.ID); err != nil { + return err + } + } + + if err = t.getMembers(e); err != nil { + return fmt.Errorf("get team members: %v", err) + } + for _, member := range t.Members { + has, err := hasAccess(e, member.ID, repo, ACCESS_MODE_READ) + if err != nil { + return err + } else if has { + continue + } + + if err = watchRepo(e, member.ID, repo.ID, false); err != nil { + return err + } + } + + return nil +} + +// RemoveRepository removes repository from team of organization. +func (t *Team) RemoveRepository(repoID int64) error { + if !t.HasRepository(repoID) { + return nil + } + + repo, err := GetRepositoryByID(repoID) + if err != nil { + return err + } + + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return err + } + + if err = t.removeRepository(sess, repo, true); err != nil { + return err + } + + return sess.Commit() +} + +var reservedTeamNames = []string{"new"} + +// IsUsableTeamName return an error if given name is a reserved name or pattern. +func IsUsableTeamName(name string) error { + return isUsableName(reservedTeamNames, nil, name) +} + +// NewTeam creates a record of new team. +// It's caller's responsibility to assign organization ID. +func NewTeam(t *Team) error { + if len(t.Name) == 0 { + return errors.New("empty team name") + } else if t.OrgID == 0 { + return errors.New("OrgID is not assigned") + } + + if err := IsUsableTeamName(t.Name); err != nil { + return err + } + + has, err := x.Id(t.OrgID).Get(new(User)) + if err != nil { + return err + } else if !has { + return ErrOrgNotExist + } + + t.LowerName = strings.ToLower(t.Name) + has, err = x.Where("org_id=?", t.OrgID).And("lower_name=?", t.LowerName).Get(new(Team)) + if err != nil { + return err + } else if has { + return ErrTeamAlreadyExist{t.OrgID, t.LowerName} + } + + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return err + } + + if _, err = sess.Insert(t); err != nil { + sess.Rollback() + return err + } + + // Update organization number of teams. + if _, err = sess.Exec("UPDATE `user` SET num_teams=num_teams+1 WHERE id = ?", t.OrgID); err != nil { + sess.Rollback() + return err + } + return sess.Commit() +} + +func getTeamOfOrgByName(e Engine, orgID int64, name string) (*Team, error) { + t := &Team{ + OrgID: orgID, + LowerName: strings.ToLower(name), + } + has, err := e.Get(t) + if err != nil { + return nil, err + } else if !has { + return nil, errors.TeamNotExist{0, name} + } + return t, nil +} + +// GetTeamOfOrgByName returns team by given team name and organization. +func GetTeamOfOrgByName(orgID int64, name string) (*Team, error) { + return getTeamOfOrgByName(x, orgID, name) +} + +func getTeamByID(e Engine, teamID int64) (*Team, error) { + t := new(Team) + has, err := e.ID(teamID).Get(t) + if err != nil { + return nil, err + } else if !has { + return nil, errors.TeamNotExist{teamID, ""} + } + return t, nil +} + +// GetTeamByID returns team by given ID. +func GetTeamByID(teamID int64) (*Team, error) { + return getTeamByID(x, teamID) +} + +func getTeamsByOrgID(e Engine, orgID int64) ([]*Team, error) { + teams := make([]*Team, 0, 3) + return teams, e.Where("org_id = ?", orgID).Find(&teams) +} + +// GetTeamsByOrgID returns all teams belong to given organization. +func GetTeamsByOrgID(orgID int64) ([]*Team, error) { + return getTeamsByOrgID(x, orgID) +} + +// UpdateTeam updates information of team. +func UpdateTeam(t *Team, authChanged bool) (err error) { + if len(t.Name) == 0 { + return errors.New("empty team name") + } + + if len(t.Description) > 255 { + t.Description = t.Description[:255] + } + + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return err + } + + t.LowerName = strings.ToLower(t.Name) + has, err := x.Where("org_id=?", t.OrgID).And("lower_name=?", t.LowerName).And("id!=?", t.ID).Get(new(Team)) + if err != nil { + return err + } else if has { + return ErrTeamAlreadyExist{t.OrgID, t.LowerName} + } + + if _, err = sess.ID(t.ID).AllCols().Update(t); err != nil { + return fmt.Errorf("update: %v", err) + } + + // Update access for team members if needed. + if authChanged { + if err = t.getRepositories(sess); err != nil { + return fmt.Errorf("getRepositories:%v", err) + } + + for _, repo := range t.Repos { + if err = repo.recalculateTeamAccesses(sess, 0); err != nil { + return fmt.Errorf("recalculateTeamAccesses: %v", err) + } + } + } + + return sess.Commit() +} + +// DeleteTeam deletes given team. +// It's caller's responsibility to assign organization ID. +func DeleteTeam(t *Team) error { + if err := t.GetRepositories(); err != nil { + return err + } + + // Get organization. + org, err := GetUserByID(t.OrgID) + if err != nil { + return err + } + + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return err + } + + // Delete all accesses. + for _, repo := range t.Repos { + if err = repo.recalculateTeamAccesses(sess, t.ID); err != nil { + return err + } + } + + // Delete team-user. + if _, err = sess.Where("org_id=?", org.ID).Where("team_id=?", t.ID).Delete(new(TeamUser)); err != nil { + return err + } + + // Delete team. + if _, err = sess.ID(t.ID).Delete(new(Team)); err != nil { + return err + } + // Update organization number of teams. + if _, err = sess.Exec("UPDATE `user` SET num_teams=num_teams-1 WHERE id=?", t.OrgID); err != nil { + return err + } + + return sess.Commit() +} + +// ___________ ____ ___ +// \__ ___/___ _____ _____ | | \______ ___________ +// | |_/ __ \\__ \ / \| | / ___// __ \_ __ \ +// | |\ ___/ / __ \| Y Y \ | /\___ \\ ___/| | \/ +// |____| \___ >____ /__|_| /______//____ >\___ >__| +// \/ \/ \/ \/ \/ + +// TeamUser represents an team-user relation. +type TeamUser struct { + ID int64 + OrgID int64 `xorm:"INDEX"` + TeamID int64 `xorm:"UNIQUE(s)"` + UID int64 `xorm:"UNIQUE(s)"` +} + +func isTeamMember(e Engine, orgID, teamID, uid int64) bool { + has, _ := e.Where("org_id=?", orgID).And("team_id=?", teamID).And("uid=?", uid).Get(new(TeamUser)) + return has +} + +// IsTeamMember returns true if given user is a member of team. +func IsTeamMember(orgID, teamID, uid int64) bool { + return isTeamMember(x, orgID, teamID, uid) +} + +func getTeamMembers(e Engine, teamID int64) (_ []*User, err error) { + teamUsers := make([]*TeamUser, 0, 10) + if err = e.Sql("SELECT `id`, `org_id`, `team_id`, `uid` FROM `team_user` WHERE team_id = ?", teamID). + Find(&teamUsers); err != nil { + return nil, fmt.Errorf("get team-users: %v", err) + } + members := make([]*User, 0, len(teamUsers)) + for i := range teamUsers { + member := new(User) + if _, err = e.ID(teamUsers[i].UID).Get(member); err != nil { + return nil, fmt.Errorf("get user '%d': %v", teamUsers[i].UID, err) + } + members = append(members, member) + } + return members, nil +} + +// GetTeamMembers returns all members in given team of organization. +func GetTeamMembers(teamID int64) ([]*User, error) { + return getTeamMembers(x, teamID) +} + +func getUserTeams(e Engine, orgID, userID int64) ([]*Team, error) { + teamUsers := make([]*TeamUser, 0, 5) + if err := e.Where("uid = ?", userID).And("org_id = ?", orgID).Find(&teamUsers); err != nil { + return nil, err + } + + teamIDs := make([]int64, len(teamUsers)+1) + for i := range teamUsers { + teamIDs[i] = teamUsers[i].TeamID + } + teamIDs[len(teamUsers)] = -1 + + teams := make([]*Team, 0, len(teamIDs)) + return teams, e.Where("org_id = ?", orgID).In("id", teamIDs).Find(&teams) +} + +// GetUserTeams returns all teams that user belongs to in given organization. +func GetUserTeams(orgID, userID int64) ([]*Team, error) { + return getUserTeams(x, orgID, userID) +} + +// AddTeamMember adds new membership of given team to given organization, +// the user will have membership to given organization automatically when needed. +func AddTeamMember(orgID, teamID, userID int64) error { + if IsTeamMember(orgID, teamID, userID) { + return nil + } + + if err := AddOrgUser(orgID, userID); err != nil { + return err + } + + // Get team and its repositories. + t, err := GetTeamByID(teamID) + if err != nil { + return err + } + t.NumMembers++ + + if err = t.GetRepositories(); err != nil { + return err + } + + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return err + } + + tu := &TeamUser{ + UID: userID, + OrgID: orgID, + TeamID: teamID, + } + if _, err = sess.Insert(tu); err != nil { + return err + } else if _, err = sess.ID(t.ID).Update(t); err != nil { + return err + } + + // Give access to team repositories. + for _, repo := range t.Repos { + if err = repo.recalculateTeamAccesses(sess, 0); err != nil { + return err + } + } + + // We make sure it exists before. + ou := new(OrgUser) + if _, err = sess.Where("uid = ?", userID).And("org_id = ?", orgID).Get(ou); err != nil { + return err + } + ou.NumTeams++ + if t.IsOwnerTeam() { + ou.IsOwner = true + } + if _, err = sess.ID(ou.ID).AllCols().Update(ou); err != nil { + return err + } + + return sess.Commit() +} + +func removeTeamMember(e Engine, orgID, teamID, uid int64) error { + if !isTeamMember(e, orgID, teamID, uid) { + return nil + } + + // Get team and its repositories. + t, err := getTeamByID(e, teamID) + if err != nil { + return err + } + + // Check if the user to delete is the last member in owner team. + if t.IsOwnerTeam() && t.NumMembers == 1 { + return ErrLastOrgOwner{UID: uid} + } + + t.NumMembers-- + + if err = t.getRepositories(e); err != nil { + return err + } + + // Get organization. + org, err := getUserByID(e, orgID) + if err != nil { + return err + } + + tu := &TeamUser{ + UID: uid, + OrgID: orgID, + TeamID: teamID, + } + if _, err := e.Delete(tu); err != nil { + return err + } else if _, err = e.ID(t.ID).AllCols().Update(t); err != nil { + return err + } + + // Delete access to team repositories. + for _, repo := range t.Repos { + if err = repo.recalculateTeamAccesses(e, 0); err != nil { + return err + } + } + + // This must exist. + ou := new(OrgUser) + _, err = e.Where("uid = ?", uid).And("org_id = ?", org.ID).Get(ou) + if err != nil { + return err + } + ou.NumTeams-- + if t.IsOwnerTeam() { + ou.IsOwner = false + } + if _, err = e.ID(ou.ID).AllCols().Update(ou); err != nil { + return err + } + return nil +} + +// RemoveTeamMember removes member from given team of given organization. +func RemoveTeamMember(orgID, teamID, uid int64) error { + sess := x.NewSession() + defer sess.Close() + if err := sess.Begin(); err != nil { + return err + } + if err := removeTeamMember(sess, orgID, teamID, uid); err != nil { + return err + } + return sess.Commit() +} + +// ___________ __________ +// \__ ___/___ _____ _____\______ \ ____ ______ ____ +// | |_/ __ \\__ \ / \| _// __ \\____ \ / _ \ +// | |\ ___/ / __ \| Y Y \ | \ ___/| |_> > <_> ) +// |____| \___ >____ /__|_| /____|_ /\___ > __/ \____/ +// \/ \/ \/ \/ \/|__| + +// TeamRepo represents an team-repository relation. +type TeamRepo struct { + ID int64 + OrgID int64 `xorm:"INDEX"` + TeamID int64 `xorm:"UNIQUE(s)"` + RepoID int64 `xorm:"UNIQUE(s)"` +} + +func hasTeamRepo(e Engine, orgID, teamID, repoID int64) bool { + has, _ := e.Where("org_id = ?", orgID).And("team_id = ?", teamID).And("repo_id = ?", repoID).Get(new(TeamRepo)) + return has +} + +// HasTeamRepo returns true if given team has access to the repository of the organization. +func HasTeamRepo(orgID, teamID, repoID int64) bool { + return hasTeamRepo(x, orgID, teamID, repoID) +} + +func addTeamRepo(e Engine, orgID, teamID, repoID int64) error { + _, err := e.InsertOne(&TeamRepo{ + OrgID: orgID, + TeamID: teamID, + RepoID: repoID, + }) + return err +} + +// AddTeamRepo adds new repository relation to team. +func AddTeamRepo(orgID, teamID, repoID int64) error { + return addTeamRepo(x, orgID, teamID, repoID) +} + +func removeTeamRepo(e Engine, teamID, repoID int64) error { + _, err := e.Delete(&TeamRepo{ + TeamID: teamID, + RepoID: repoID, + }) + return err +} + +// RemoveTeamRepo deletes repository relation to team. +func RemoveTeamRepo(teamID, repoID int64) error { + return removeTeamRepo(x, teamID, repoID) +} + +// GetTeamsHaveAccessToRepo returns all teams in an organization that have given access level to the repository. +func GetTeamsHaveAccessToRepo(orgID, repoID int64, mode AccessMode) ([]*Team, error) { + teams := make([]*Team, 0, 5) + return teams, x.Where("team.authorize >= ?", mode). + Join("INNER", "team_repo", "team_repo.team_id = team.id"). + And("team_repo.org_id = ?", orgID). + And("team_repo.repo_id = ?", repoID). + Find(&teams) +} diff --git a/internal/db/pull.go b/internal/db/pull.go new file mode 100644 index 00000000..30179eb2 --- /dev/null +++ b/internal/db/pull.go @@ -0,0 +1,851 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package db + +import ( + "fmt" + "os" + "path" + "strings" + "time" + + "github.com/unknwon/com" + log "gopkg.in/clog.v1" + "xorm.io/xorm" + + "github.com/gogs/git-module" + api "github.com/gogs/go-gogs-client" + + "gogs.io/gogs/internal/db/errors" + "gogs.io/gogs/internal/process" + "gogs.io/gogs/internal/setting" + "gogs.io/gogs/internal/sync" +) + +var PullRequestQueue = sync.NewUniqueQueue(setting.Repository.PullRequestQueueLength) + +type PullRequestType int + +const ( + PULL_REQUEST_GOGS PullRequestType = iota + PLLL_ERQUEST_GIT +) + +type PullRequestStatus int + +const ( + PULL_REQUEST_STATUS_CONFLICT PullRequestStatus = iota + PULL_REQUEST_STATUS_CHECKING + PULL_REQUEST_STATUS_MERGEABLE +) + +// PullRequest represents relation between pull request and repositories. +type PullRequest struct { + ID int64 + Type PullRequestType + Status PullRequestStatus + + IssueID int64 `xorm:"INDEX"` + Issue *Issue `xorm:"-" json:"-"` + Index int64 + + HeadRepoID int64 + HeadRepo *Repository `xorm:"-" json:"-"` + BaseRepoID int64 + BaseRepo *Repository `xorm:"-" json:"-"` + HeadUserName string + HeadBranch string + BaseBranch string + MergeBase string `xorm:"VARCHAR(40)"` + + HasMerged bool + MergedCommitID string `xorm:"VARCHAR(40)"` + MergerID int64 + Merger *User `xorm:"-" json:"-"` + Merged time.Time `xorm:"-" json:"-"` + MergedUnix int64 +} + +func (pr *PullRequest) BeforeUpdate() { + pr.MergedUnix = pr.Merged.Unix() +} + +// Note: don't try to get Issue because will end up recursive querying. +func (pr *PullRequest) AfterSet(colName string, _ xorm.Cell) { + switch colName { + case "merged_unix": + if !pr.HasMerged { + return + } + + pr.Merged = time.Unix(pr.MergedUnix, 0).Local() + } +} + +// Note: don't try to get Issue because will end up recursive querying. +func (pr *PullRequest) loadAttributes(e Engine) (err error) { + if pr.HeadRepo == nil { + pr.HeadRepo, err = getRepositoryByID(e, pr.HeadRepoID) + if err != nil && !errors.IsRepoNotExist(err) { + return fmt.Errorf("getRepositoryByID.(HeadRepo) [%d]: %v", pr.HeadRepoID, err) + } + } + + if pr.BaseRepo == nil { + pr.BaseRepo, err = getRepositoryByID(e, pr.BaseRepoID) + if err != nil { + return fmt.Errorf("getRepositoryByID.(BaseRepo) [%d]: %v", pr.BaseRepoID, err) + } + } + + if pr.HasMerged && pr.Merger == nil { + pr.Merger, err = getUserByID(e, pr.MergerID) + if errors.IsUserNotExist(err) { + pr.MergerID = -1 + pr.Merger = NewGhostUser() + } else if err != nil { + return fmt.Errorf("getUserByID [%d]: %v", pr.MergerID, err) + } + } + + return nil +} + +func (pr *PullRequest) LoadAttributes() error { + return pr.loadAttributes(x) +} + +func (pr *PullRequest) LoadIssue() (err error) { + if pr.Issue != nil { + return nil + } + + pr.Issue, err = GetIssueByID(pr.IssueID) + return err +} + +// This method assumes following fields have been assigned with valid values: +// Required - Issue, BaseRepo +// Optional - HeadRepo, Merger +func (pr *PullRequest) APIFormat() *api.PullRequest { + // In case of head repo has been deleted. + var apiHeadRepo *api.Repository + if pr.HeadRepo == nil { + apiHeadRepo = &api.Repository{ + Name: "deleted", + } + } else { + apiHeadRepo = pr.HeadRepo.APIFormat(nil) + } + + apiIssue := pr.Issue.APIFormat() + apiPullRequest := &api.PullRequest{ + ID: pr.ID, + Index: pr.Index, + Poster: apiIssue.Poster, + Title: apiIssue.Title, + Body: apiIssue.Body, + Labels: apiIssue.Labels, + Milestone: apiIssue.Milestone, + Assignee: apiIssue.Assignee, + State: apiIssue.State, + Comments: apiIssue.Comments, + HeadBranch: pr.HeadBranch, + HeadRepo: apiHeadRepo, + BaseBranch: pr.BaseBranch, + BaseRepo: pr.BaseRepo.APIFormat(nil), + HTMLURL: pr.Issue.HTMLURL(), + HasMerged: pr.HasMerged, + } + + if pr.Status != PULL_REQUEST_STATUS_CHECKING { + mergeable := pr.Status != PULL_REQUEST_STATUS_CONFLICT + apiPullRequest.Mergeable = &mergeable + } + if pr.HasMerged { + apiPullRequest.Merged = &pr.Merged + apiPullRequest.MergedCommitID = &pr.MergedCommitID + apiPullRequest.MergedBy = pr.Merger.APIFormat() + } + + return apiPullRequest +} + +// IsChecking returns true if this pull request is still checking conflict. +func (pr *PullRequest) IsChecking() bool { + return pr.Status == PULL_REQUEST_STATUS_CHECKING +} + +// CanAutoMerge returns true if this pull request can be merged automatically. +func (pr *PullRequest) CanAutoMerge() bool { + return pr.Status == PULL_REQUEST_STATUS_MERGEABLE +} + +// MergeStyle represents the approach to merge commits into base branch. +type MergeStyle string + +const ( + MERGE_STYLE_REGULAR MergeStyle = "create_merge_commit" + MERGE_STYLE_REBASE MergeStyle = "rebase_before_merging" +) + +// Merge merges pull request to base repository. +// FIXME: add repoWorkingPull make sure two merges does not happen at same time. +func (pr *PullRequest) Merge(doer *User, baseGitRepo *git.Repository, mergeStyle MergeStyle, commitDescription string) (err error) { + defer func() { + go HookQueue.Add(pr.BaseRepo.ID) + go AddTestPullRequestTask(doer, pr.BaseRepo.ID, pr.BaseBranch, false) + }() + + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return err + } + + if err = pr.Issue.changeStatus(sess, doer, pr.Issue.Repo, true); err != nil { + return fmt.Errorf("Issue.changeStatus: %v", err) + } + + headRepoPath := RepoPath(pr.HeadUserName, pr.HeadRepo.Name) + headGitRepo, err := git.OpenRepository(headRepoPath) + if err != nil { + return fmt.Errorf("OpenRepository: %v", err) + } + + // Create temporary directory to store temporary copy of the base repository, + // and clean it up when operation finished regardless of succeed or not. + tmpBasePath := path.Join(setting.AppDataPath, "tmp/repos", com.ToStr(time.Now().Nanosecond())+".git") + os.MkdirAll(path.Dir(tmpBasePath), os.ModePerm) + defer os.RemoveAll(path.Dir(tmpBasePath)) + + // Clone the base repository to the defined temporary directory, + // and checks out to base branch directly. + var stderr string + if _, stderr, err = process.ExecTimeout(5*time.Minute, + fmt.Sprintf("PullRequest.Merge (git clone): %s", tmpBasePath), + "git", "clone", "-b", pr.BaseBranch, baseGitRepo.Path, tmpBasePath); err != nil { + return fmt.Errorf("git clone: %s", stderr) + } + + // Add remote which points to the head repository. + if _, stderr, err = process.ExecDir(-1, tmpBasePath, + fmt.Sprintf("PullRequest.Merge (git remote add): %s", tmpBasePath), + "git", "remote", "add", "head_repo", headRepoPath); err != nil { + return fmt.Errorf("git remote add [%s -> %s]: %s", headRepoPath, tmpBasePath, stderr) + } + + // Fetch information from head repository to the temporary copy. + if _, stderr, err = process.ExecDir(-1, tmpBasePath, + fmt.Sprintf("PullRequest.Merge (git fetch): %s", tmpBasePath), + "git", "fetch", "head_repo"); err != nil { + return fmt.Errorf("git fetch [%s -> %s]: %s", headRepoPath, tmpBasePath, stderr) + } + + remoteHeadBranch := "head_repo/" + pr.HeadBranch + + // Check if merge style is allowed, reset to default style if not + if mergeStyle == MERGE_STYLE_REBASE && !pr.BaseRepo.PullsAllowRebase { + mergeStyle = MERGE_STYLE_REGULAR + } + + switch mergeStyle { + case MERGE_STYLE_REGULAR: // Create merge commit + + // Merge changes from head branch. + if _, stderr, err = process.ExecDir(-1, tmpBasePath, + fmt.Sprintf("PullRequest.Merge (git merge --no-ff --no-commit): %s", tmpBasePath), + "git", "merge", "--no-ff", "--no-commit", remoteHeadBranch); err != nil { + return fmt.Errorf("git merge --no-ff --no-commit [%s]: %v - %s", tmpBasePath, err, stderr) + } + + // Create a merge commit for the base branch. + sig := doer.NewGitSig() + if _, stderr, err = process.ExecDir(-1, tmpBasePath, + fmt.Sprintf("PullRequest.Merge (git merge): %s", tmpBasePath), + "git", "commit", fmt.Sprintf("--author='%s <%s>'", sig.Name, sig.Email), + "-m", fmt.Sprintf("Merge branch '%s' of %s/%s into %s", pr.HeadBranch, pr.HeadUserName, pr.HeadRepo.Name, pr.BaseBranch), + "-m", commitDescription); err != nil { + return fmt.Errorf("git commit [%s]: %v - %s", tmpBasePath, err, stderr) + } + + case MERGE_STYLE_REBASE: // Rebase before merging + + // Rebase head branch based on base branch, this creates a non-branch commit state. + if _, stderr, err = process.ExecDir(-1, tmpBasePath, + fmt.Sprintf("PullRequest.Merge (git rebase): %s", tmpBasePath), + "git", "rebase", "--quiet", pr.BaseBranch, remoteHeadBranch); err != nil { + return fmt.Errorf("git rebase [%s on %s]: %s", remoteHeadBranch, pr.BaseBranch, stderr) + } + + // Name non-branch commit state to a new temporary branch in order to save changes. + tmpBranch := com.ToStr(time.Now().UnixNano(), 10) + if _, stderr, err = process.ExecDir(-1, tmpBasePath, + fmt.Sprintf("PullRequest.Merge (git checkout): %s", tmpBasePath), + "git", "checkout", "-b", tmpBranch); err != nil { + return fmt.Errorf("git checkout '%s': %s", tmpBranch, stderr) + } + + // Check out the base branch to be operated on. + if _, stderr, err = process.ExecDir(-1, tmpBasePath, + fmt.Sprintf("PullRequest.Merge (git checkout): %s", tmpBasePath), + "git", "checkout", pr.BaseBranch); err != nil { + return fmt.Errorf("git checkout '%s': %s", pr.BaseBranch, stderr) + } + + // Merge changes from temporary branch to the base branch. + if _, stderr, err = process.ExecDir(-1, tmpBasePath, + fmt.Sprintf("PullRequest.Merge (git merge): %s", tmpBasePath), + "git", "merge", tmpBranch); err != nil { + return fmt.Errorf("git merge [%s]: %v - %s", tmpBasePath, err, stderr) + } + + default: + return fmt.Errorf("unknown merge style: %s", mergeStyle) + } + + // Push changes on base branch to upstream. + if _, stderr, err = process.ExecDir(-1, tmpBasePath, + fmt.Sprintf("PullRequest.Merge (git push): %s", tmpBasePath), + "git", "push", baseGitRepo.Path, pr.BaseBranch); err != nil { + return fmt.Errorf("git push: %s", stderr) + } + + pr.MergedCommitID, err = headGitRepo.GetBranchCommitID(pr.HeadBranch) + if err != nil { + return fmt.Errorf("GetBranchCommit: %v", err) + } + + pr.HasMerged = true + pr.Merged = time.Now() + pr.MergerID = doer.ID + if _, err = sess.ID(pr.ID).AllCols().Update(pr); err != nil { + return fmt.Errorf("update pull request: %v", err) + } + + if err = sess.Commit(); err != nil { + return fmt.Errorf("Commit: %v", err) + } + + if err = MergePullRequestAction(doer, pr.Issue.Repo, pr.Issue); err != nil { + log.Error(2, "MergePullRequestAction [%d]: %v", pr.ID, err) + } + + // Reload pull request information. + if err = pr.LoadAttributes(); err != nil { + log.Error(2, "LoadAttributes: %v", err) + return nil + } + if err = PrepareWebhooks(pr.Issue.Repo, HOOK_EVENT_PULL_REQUEST, &api.PullRequestPayload{ + Action: api.HOOK_ISSUE_CLOSED, + Index: pr.Index, + PullRequest: pr.APIFormat(), + Repository: pr.Issue.Repo.APIFormat(nil), + Sender: doer.APIFormat(), + }); err != nil { + log.Error(2, "PrepareWebhooks: %v", err) + return nil + } + + l, err := headGitRepo.CommitsBetweenIDs(pr.MergedCommitID, pr.MergeBase) + if err != nil { + log.Error(2, "CommitsBetweenIDs: %v", err) + return nil + } + + // It is possible that head branch is not fully sync with base branch for merge commits, + // so we need to get latest head commit and append merge commit manully + // to avoid strange diff commits produced. + mergeCommit, err := baseGitRepo.GetBranchCommit(pr.BaseBranch) + if err != nil { + log.Error(2, "GetBranchCommit: %v", err) + return nil + } + if mergeStyle == MERGE_STYLE_REGULAR { + l.PushFront(mergeCommit) + } + + commits, err := ListToPushCommits(l).ToApiPayloadCommits(pr.BaseRepo.RepoPath(), pr.BaseRepo.HTMLURL()) + if err != nil { + log.Error(2, "ToApiPayloadCommits: %v", err) + return nil + } + + p := &api.PushPayload{ + Ref: git.BRANCH_PREFIX + pr.BaseBranch, + Before: pr.MergeBase, + After: mergeCommit.ID.String(), + CompareURL: setting.AppURL + pr.BaseRepo.ComposeCompareURL(pr.MergeBase, pr.MergedCommitID), + Commits: commits, + Repo: pr.BaseRepo.APIFormat(nil), + Pusher: pr.HeadRepo.MustOwner().APIFormat(), + Sender: doer.APIFormat(), + } + if err = PrepareWebhooks(pr.BaseRepo, HOOK_EVENT_PUSH, p); err != nil { + log.Error(2, "PrepareWebhooks: %v", err) + return nil + } + return nil +} + +// testPatch checks if patch can be merged to base repository without conflit. +// FIXME: make a mechanism to clean up stable local copies. +func (pr *PullRequest) testPatch() (err error) { + if pr.BaseRepo == nil { + pr.BaseRepo, err = GetRepositoryByID(pr.BaseRepoID) + if err != nil { + return fmt.Errorf("GetRepositoryByID: %v", err) + } + } + + patchPath, err := pr.BaseRepo.PatchPath(pr.Index) + if err != nil { + return fmt.Errorf("BaseRepo.PatchPath: %v", err) + } + + // Fast fail if patch does not exist, this assumes data is cruppted. + if !com.IsFile(patchPath) { + log.Trace("PullRequest[%d].testPatch: ignored cruppted data", pr.ID) + return nil + } + + repoWorkingPool.CheckIn(com.ToStr(pr.BaseRepoID)) + defer repoWorkingPool.CheckOut(com.ToStr(pr.BaseRepoID)) + + log.Trace("PullRequest[%d].testPatch (patchPath): %s", pr.ID, patchPath) + + if err := pr.BaseRepo.UpdateLocalCopyBranch(pr.BaseBranch); err != nil { + return fmt.Errorf("UpdateLocalCopy [%d]: %v", pr.BaseRepoID, err) + } + + args := []string{"apply", "--check"} + if pr.BaseRepo.PullsIgnoreWhitespace { + args = append(args, "--ignore-whitespace") + } + args = append(args, patchPath) + + pr.Status = PULL_REQUEST_STATUS_CHECKING + _, stderr, err := process.ExecDir(-1, pr.BaseRepo.LocalCopyPath(), + fmt.Sprintf("testPatch (git apply --check): %d", pr.BaseRepo.ID), + "git", args...) + if err != nil { + log.Trace("PullRequest[%d].testPatch (apply): has conflit\n%s", pr.ID, stderr) + pr.Status = PULL_REQUEST_STATUS_CONFLICT + return nil + } + return nil +} + +// NewPullRequest creates new pull request with labels for repository. +func NewPullRequest(repo *Repository, pull *Issue, labelIDs []int64, uuids []string, pr *PullRequest, patch []byte) (err error) { + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return err + } + + if err = newIssue(sess, NewIssueOptions{ + Repo: repo, + Issue: pull, + LableIDs: labelIDs, + Attachments: uuids, + IsPull: true, + }); err != nil { + return fmt.Errorf("newIssue: %v", err) + } + + pr.Index = pull.Index + if err = repo.SavePatch(pr.Index, patch); err != nil { + return fmt.Errorf("SavePatch: %v", err) + } + + pr.BaseRepo = repo + if err = pr.testPatch(); err != nil { + return fmt.Errorf("testPatch: %v", err) + } + // No conflict appears after test means mergeable. + if pr.Status == PULL_REQUEST_STATUS_CHECKING { + pr.Status = PULL_REQUEST_STATUS_MERGEABLE + } + + pr.IssueID = pull.ID + if _, err = sess.Insert(pr); err != nil { + return fmt.Errorf("insert pull repo: %v", err) + } + + if err = sess.Commit(); err != nil { + return fmt.Errorf("Commit: %v", err) + } + + if err = NotifyWatchers(&Action{ + ActUserID: pull.Poster.ID, + ActUserName: pull.Poster.Name, + OpType: ACTION_CREATE_PULL_REQUEST, + Content: fmt.Sprintf("%d|%s", pull.Index, pull.Title), + RepoID: repo.ID, + RepoUserName: repo.Owner.Name, + RepoName: repo.Name, + IsPrivate: repo.IsPrivate, + }); err != nil { + log.Error(2, "NotifyWatchers: %v", err) + } + if err = pull.MailParticipants(); err != nil { + log.Error(2, "MailParticipants: %v", err) + } + + pr.Issue = pull + pull.PullRequest = pr + if err = PrepareWebhooks(repo, HOOK_EVENT_PULL_REQUEST, &api.PullRequestPayload{ + Action: api.HOOK_ISSUE_OPENED, + Index: pull.Index, + PullRequest: pr.APIFormat(), + Repository: repo.APIFormat(nil), + Sender: pull.Poster.APIFormat(), + }); err != nil { + log.Error(2, "PrepareWebhooks: %v", err) + } + + return nil +} + +// GetUnmergedPullRequest returnss a pull request that is open and has not been merged +// by given head/base and repo/branch. +func GetUnmergedPullRequest(headRepoID, baseRepoID int64, headBranch, baseBranch string) (*PullRequest, error) { + pr := new(PullRequest) + has, err := x.Where("head_repo_id=? AND head_branch=? AND base_repo_id=? AND base_branch=? AND has_merged=? AND issue.is_closed=?", + headRepoID, headBranch, baseRepoID, baseBranch, false, false). + Join("INNER", "issue", "issue.id=pull_request.issue_id").Get(pr) + if err != nil { + return nil, err + } else if !has { + return nil, ErrPullRequestNotExist{0, 0, headRepoID, baseRepoID, headBranch, baseBranch} + } + + return pr, nil +} + +// GetUnmergedPullRequestsByHeadInfo returnss all pull requests that are open and has not been merged +// by given head information (repo and branch). +func GetUnmergedPullRequestsByHeadInfo(repoID int64, branch string) ([]*PullRequest, error) { + prs := make([]*PullRequest, 0, 2) + return prs, x.Where("head_repo_id = ? AND head_branch = ? AND has_merged = ? AND issue.is_closed = ?", + repoID, branch, false, false). + Join("INNER", "issue", "issue.id = pull_request.issue_id").Find(&prs) +} + +// GetUnmergedPullRequestsByBaseInfo returnss all pull requests that are open and has not been merged +// by given base information (repo and branch). +func GetUnmergedPullRequestsByBaseInfo(repoID int64, branch string) ([]*PullRequest, error) { + prs := make([]*PullRequest, 0, 2) + return prs, x.Where("base_repo_id=? AND base_branch=? AND has_merged=? AND issue.is_closed=?", + repoID, branch, false, false). + Join("INNER", "issue", "issue.id=pull_request.issue_id").Find(&prs) +} + +func getPullRequestByID(e Engine, id int64) (*PullRequest, error) { + pr := new(PullRequest) + has, err := e.ID(id).Get(pr) + if err != nil { + return nil, err + } else if !has { + return nil, ErrPullRequestNotExist{id, 0, 0, 0, "", ""} + } + return pr, pr.loadAttributes(e) +} + +// GetPullRequestByID returns a pull request by given ID. +func GetPullRequestByID(id int64) (*PullRequest, error) { + return getPullRequestByID(x, id) +} + +func getPullRequestByIssueID(e Engine, issueID int64) (*PullRequest, error) { + pr := &PullRequest{ + IssueID: issueID, + } + has, err := e.Get(pr) + if err != nil { + return nil, err + } else if !has { + return nil, ErrPullRequestNotExist{0, issueID, 0, 0, "", ""} + } + return pr, pr.loadAttributes(e) +} + +// GetPullRequestByIssueID returns pull request by given issue ID. +func GetPullRequestByIssueID(issueID int64) (*PullRequest, error) { + return getPullRequestByIssueID(x, issueID) +} + +// Update updates all fields of pull request. +func (pr *PullRequest) Update() error { + _, err := x.Id(pr.ID).AllCols().Update(pr) + return err +} + +// Update updates specific fields of pull request. +func (pr *PullRequest) UpdateCols(cols ...string) error { + _, err := x.Id(pr.ID).Cols(cols...).Update(pr) + return err +} + +// UpdatePatch generates and saves a new patch. +func (pr *PullRequest) UpdatePatch() (err error) { + if pr.HeadRepo == nil { + log.Trace("PullRequest[%d].UpdatePatch: ignored cruppted data", pr.ID) + return nil + } + + headGitRepo, err := git.OpenRepository(pr.HeadRepo.RepoPath()) + if err != nil { + return fmt.Errorf("OpenRepository: %v", err) + } + + // Add a temporary remote. + tmpRemote := com.ToStr(time.Now().UnixNano()) + if err = headGitRepo.AddRemote(tmpRemote, RepoPath(pr.BaseRepo.MustOwner().Name, pr.BaseRepo.Name), true); err != nil { + return fmt.Errorf("AddRemote: %v", err) + } + defer func() { + headGitRepo.RemoveRemote(tmpRemote) + }() + remoteBranch := "remotes/" + tmpRemote + "/" + pr.BaseBranch + pr.MergeBase, err = headGitRepo.GetMergeBase(remoteBranch, pr.HeadBranch) + if err != nil { + return fmt.Errorf("GetMergeBase: %v", err) + } else if err = pr.Update(); err != nil { + return fmt.Errorf("Update: %v", err) + } + + patch, err := headGitRepo.GetPatch(pr.MergeBase, pr.HeadBranch) + if err != nil { + return fmt.Errorf("GetPatch: %v", err) + } + + if err = pr.BaseRepo.SavePatch(pr.Index, patch); err != nil { + return fmt.Errorf("BaseRepo.SavePatch: %v", err) + } + + return nil +} + +// PushToBaseRepo pushes commits from branches of head repository to +// corresponding branches of base repository. +// FIXME: Only push branches that are actually updates? +func (pr *PullRequest) PushToBaseRepo() (err error) { + log.Trace("PushToBaseRepo[%d]: pushing commits to base repo 'refs/pull/%d/head'", pr.BaseRepoID, pr.Index) + + headRepoPath := pr.HeadRepo.RepoPath() + headGitRepo, err := git.OpenRepository(headRepoPath) + if err != nil { + return fmt.Errorf("OpenRepository: %v", err) + } + + tmpRemoteName := fmt.Sprintf("tmp-pull-%d", pr.ID) + if err = headGitRepo.AddRemote(tmpRemoteName, pr.BaseRepo.RepoPath(), false); err != nil { + return fmt.Errorf("headGitRepo.AddRemote: %v", err) + } + // Make sure to remove the remote even if the push fails + defer headGitRepo.RemoveRemote(tmpRemoteName) + + headFile := fmt.Sprintf("refs/pull/%d/head", pr.Index) + + // Remove head in case there is a conflict. + os.Remove(path.Join(pr.BaseRepo.RepoPath(), headFile)) + + if err = git.Push(headRepoPath, tmpRemoteName, fmt.Sprintf("%s:%s", pr.HeadBranch, headFile)); err != nil { + return fmt.Errorf("Push: %v", err) + } + + return nil +} + +// AddToTaskQueue adds itself to pull request test task queue. +func (pr *PullRequest) AddToTaskQueue() { + go PullRequestQueue.AddFunc(pr.ID, func() { + pr.Status = PULL_REQUEST_STATUS_CHECKING + if err := pr.UpdateCols("status"); err != nil { + log.Error(3, "AddToTaskQueue.UpdateCols[%d].(add to queue): %v", pr.ID, err) + } + }) +} + +type PullRequestList []*PullRequest + +func (prs PullRequestList) loadAttributes(e Engine) (err error) { + if len(prs) == 0 { + return nil + } + + // Load issues + set := make(map[int64]*Issue) + for i := range prs { + set[prs[i].IssueID] = nil + } + issueIDs := make([]int64, 0, len(prs)) + for issueID := range set { + issueIDs = append(issueIDs, issueID) + } + issues := make([]*Issue, 0, len(issueIDs)) + if err = e.Where("id > 0").In("id", issueIDs).Find(&issues); err != nil { + return fmt.Errorf("find issues: %v", err) + } + for i := range issues { + set[issues[i].ID] = issues[i] + } + for i := range prs { + prs[i].Issue = set[prs[i].IssueID] + } + + // Load attributes + for i := range prs { + if err = prs[i].loadAttributes(e); err != nil { + return fmt.Errorf("loadAttributes [%d]: %v", prs[i].ID, err) + } + } + + return nil +} + +func (prs PullRequestList) LoadAttributes() error { + return prs.loadAttributes(x) +} + +func addHeadRepoTasks(prs []*PullRequest) { + for _, pr := range prs { + log.Trace("addHeadRepoTasks[%d]: composing new test task", pr.ID) + if err := pr.UpdatePatch(); err != nil { + log.Error(4, "UpdatePatch: %v", err) + continue + } else if err := pr.PushToBaseRepo(); err != nil { + log.Error(4, "PushToBaseRepo: %v", err) + continue + } + + pr.AddToTaskQueue() + } +} + +// AddTestPullRequestTask adds new test tasks by given head/base repository and head/base branch, +// and generate new patch for testing as needed. +func AddTestPullRequestTask(doer *User, repoID int64, branch string, isSync bool) { + log.Trace("AddTestPullRequestTask [head_repo_id: %d, head_branch: %s]: finding pull requests", repoID, branch) + prs, err := GetUnmergedPullRequestsByHeadInfo(repoID, branch) + if err != nil { + log.Error(2, "Find pull requests [head_repo_id: %d, head_branch: %s]: %v", repoID, branch, err) + return + } + + if isSync { + if err = PullRequestList(prs).LoadAttributes(); err != nil { + log.Error(2, "PullRequestList.LoadAttributes: %v", err) + } + + if err == nil { + for _, pr := range prs { + pr.Issue.PullRequest = pr + if err = pr.Issue.LoadAttributes(); err != nil { + log.Error(2, "LoadAttributes: %v", err) + continue + } + if err = PrepareWebhooks(pr.Issue.Repo, HOOK_EVENT_PULL_REQUEST, &api.PullRequestPayload{ + Action: api.HOOK_ISSUE_SYNCHRONIZED, + Index: pr.Issue.Index, + PullRequest: pr.Issue.PullRequest.APIFormat(), + Repository: pr.Issue.Repo.APIFormat(nil), + Sender: doer.APIFormat(), + }); err != nil { + log.Error(2, "PrepareWebhooks [pull_id: %v]: %v", pr.ID, err) + continue + } + } + } + } + + addHeadRepoTasks(prs) + + log.Trace("AddTestPullRequestTask [base_repo_id: %d, base_branch: %s]: finding pull requests", repoID, branch) + prs, err = GetUnmergedPullRequestsByBaseInfo(repoID, branch) + if err != nil { + log.Error(2, "Find pull requests [base_repo_id: %d, base_branch: %s]: %v", repoID, branch, err) + return + } + for _, pr := range prs { + pr.AddToTaskQueue() + } +} + +func ChangeUsernameInPullRequests(oldUserName, newUserName string) error { + pr := PullRequest{ + HeadUserName: strings.ToLower(newUserName), + } + _, err := x.Cols("head_user_name").Where("head_user_name = ?", strings.ToLower(oldUserName)).Update(pr) + return err +} + +// checkAndUpdateStatus checks if pull request is possible to levaing checking status, +// and set to be either conflict or mergeable. +func (pr *PullRequest) checkAndUpdateStatus() { + // Status is not changed to conflict means mergeable. + if pr.Status == PULL_REQUEST_STATUS_CHECKING { + pr.Status = PULL_REQUEST_STATUS_MERGEABLE + } + + // Make sure there is no waiting test to process before levaing the checking status. + if !PullRequestQueue.Exist(pr.ID) { + if err := pr.UpdateCols("status"); err != nil { + log.Error(4, "Update[%d]: %v", pr.ID, err) + } + } +} + +// TestPullRequests checks and tests untested patches of pull requests. +// TODO: test more pull requests at same time. +func TestPullRequests() { + prs := make([]*PullRequest, 0, 10) + x.Iterate(PullRequest{ + Status: PULL_REQUEST_STATUS_CHECKING, + }, + func(idx int, bean interface{}) error { + pr := bean.(*PullRequest) + + if err := pr.LoadAttributes(); err != nil { + log.Error(3, "LoadAttributes: %v", err) + return nil + } + + if err := pr.testPatch(); err != nil { + log.Error(3, "testPatch: %v", err) + return nil + } + prs = append(prs, pr) + return nil + }) + + // Update pull request status. + for _, pr := range prs { + pr.checkAndUpdateStatus() + } + + // Start listening on new test requests. + for prID := range PullRequestQueue.Queue() { + log.Trace("TestPullRequests[%v]: processing test task", prID) + PullRequestQueue.Remove(prID) + + pr, err := GetPullRequestByID(com.StrTo(prID).MustInt64()) + if err != nil { + log.Error(4, "GetPullRequestByID[%s]: %v", prID, err) + continue + } else if err = pr.testPatch(); err != nil { + log.Error(4, "testPatch[%d]: %v", pr.ID, err) + continue + } + + pr.checkAndUpdateStatus() + } +} + +func InitTestPullRequests() { + go TestPullRequests() +} diff --git a/internal/db/release.go b/internal/db/release.go new file mode 100644 index 00000000..7712a0af --- /dev/null +++ b/internal/db/release.go @@ -0,0 +1,352 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package db + +import ( + "fmt" + "sort" + "strings" + "time" + + log "gopkg.in/clog.v1" + "xorm.io/xorm" + + "github.com/gogs/git-module" + api "github.com/gogs/go-gogs-client" + + "gogs.io/gogs/internal/db/errors" + "gogs.io/gogs/internal/process" +) + +// Release represents a release of repository. +type Release struct { + ID int64 + RepoID int64 + Repo *Repository `xorm:"-" json:"-"` + PublisherID int64 + Publisher *User `xorm:"-" json:"-"` + TagName string + LowerTagName string + Target string + Title string + Sha1 string `xorm:"VARCHAR(40)"` + NumCommits int64 + NumCommitsBehind int64 `xorm:"-" json:"-"` + Note string `xorm:"TEXT"` + IsDraft bool `xorm:"NOT NULL DEFAULT false"` + IsPrerelease bool + + Created time.Time `xorm:"-" json:"-"` + CreatedUnix int64 + + Attachments []*Attachment `xorm:"-" json:"-"` +} + +func (r *Release) BeforeInsert() { + if r.CreatedUnix == 0 { + r.CreatedUnix = time.Now().Unix() + } +} + +func (r *Release) AfterSet(colName string, _ xorm.Cell) { + switch colName { + case "created_unix": + r.Created = time.Unix(r.CreatedUnix, 0).Local() + } +} + +func (r *Release) loadAttributes(e Engine) (err error) { + if r.Repo == nil { + r.Repo, err = getRepositoryByID(e, r.RepoID) + if err != nil { + return fmt.Errorf("getRepositoryByID [repo_id: %d]: %v", r.RepoID, err) + } + } + + if r.Publisher == nil { + r.Publisher, err = getUserByID(e, r.PublisherID) + if err != nil { + if errors.IsUserNotExist(err) { + r.PublisherID = -1 + r.Publisher = NewGhostUser() + } else { + return fmt.Errorf("getUserByID.(Publisher) [publisher_id: %d]: %v", r.PublisherID, err) + } + } + } + + if r.Attachments == nil { + r.Attachments, err = getAttachmentsByReleaseID(e, r.ID) + if err != nil { + return fmt.Errorf("getAttachmentsByReleaseID [%d]: %v", r.ID, err) + } + } + + return nil +} + +func (r *Release) LoadAttributes() error { + return r.loadAttributes(x) +} + +// This method assumes some fields assigned with values: +// Required - Publisher +func (r *Release) APIFormat() *api.Release { + return &api.Release{ + ID: r.ID, + TagName: r.TagName, + TargetCommitish: r.Target, + Name: r.Title, + Body: r.Note, + Draft: r.IsDraft, + Prerelease: r.IsPrerelease, + Author: r.Publisher.APIFormat(), + Created: r.Created, + } +} + +// IsReleaseExist returns true if release with given tag name already exists. +func IsReleaseExist(repoID int64, tagName string) (bool, error) { + if len(tagName) == 0 { + return false, nil + } + + return x.Get(&Release{RepoID: repoID, LowerTagName: strings.ToLower(tagName)}) +} + +func createTag(gitRepo *git.Repository, r *Release) error { + // Only actual create when publish. + if !r.IsDraft { + if !gitRepo.IsTagExist(r.TagName) { + commit, err := gitRepo.GetBranchCommit(r.Target) + if err != nil { + return fmt.Errorf("GetBranchCommit: %v", err) + } + + // Trim '--' prefix to prevent command line argument vulnerability. + r.TagName = strings.TrimPrefix(r.TagName, "--") + if err = gitRepo.CreateTag(r.TagName, commit.ID.String()); err != nil { + if strings.Contains(err.Error(), "is not a valid tag name") { + return ErrInvalidTagName{r.TagName} + } + return err + } + } else { + commit, err := gitRepo.GetTagCommit(r.TagName) + if err != nil { + return fmt.Errorf("GetTagCommit: %v", err) + } + + r.Sha1 = commit.ID.String() + r.NumCommits, err = commit.CommitsCount() + if err != nil { + return fmt.Errorf("CommitsCount: %v", err) + } + } + } + return nil +} + +func (r *Release) preparePublishWebhooks() { + if err := PrepareWebhooks(r.Repo, HOOK_EVENT_RELEASE, &api.ReleasePayload{ + Action: api.HOOK_RELEASE_PUBLISHED, + Release: r.APIFormat(), + Repository: r.Repo.APIFormat(nil), + Sender: r.Publisher.APIFormat(), + }); err != nil { + log.Error(2, "PrepareWebhooks: %v", err) + } +} + +// NewRelease creates a new release with attachments for repository. +func NewRelease(gitRepo *git.Repository, r *Release, uuids []string) error { + isExist, err := IsReleaseExist(r.RepoID, r.TagName) + if err != nil { + return err + } else if isExist { + return ErrReleaseAlreadyExist{r.TagName} + } + + if err = createTag(gitRepo, r); err != nil { + return err + } + r.LowerTagName = strings.ToLower(r.TagName) + + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return err + } + + if _, err = sess.Insert(r); err != nil { + return fmt.Errorf("Insert: %v", err) + } + + if len(uuids) > 0 { + if _, err = sess.In("uuid", uuids).Cols("release_id").Update(&Attachment{ReleaseID: r.ID}); err != nil { + return fmt.Errorf("link attachments: %v", err) + } + } + + if err = sess.Commit(); err != nil { + return fmt.Errorf("Commit: %v", err) + } + + // Only send webhook when actually published, skip drafts + if r.IsDraft { + return nil + } + r, err = GetReleaseByID(r.ID) + if err != nil { + return fmt.Errorf("GetReleaseByID: %v", err) + } + r.preparePublishWebhooks() + return nil +} + +// GetRelease returns release by given ID. +func GetRelease(repoID int64, tagName string) (*Release, error) { + isExist, err := IsReleaseExist(repoID, tagName) + if err != nil { + return nil, err + } else if !isExist { + return nil, ErrReleaseNotExist{0, tagName} + } + + r := &Release{RepoID: repoID, LowerTagName: strings.ToLower(tagName)} + if _, err = x.Get(r); err != nil { + return nil, fmt.Errorf("Get: %v", err) + } + + return r, r.LoadAttributes() +} + +// GetReleaseByID returns release with given ID. +func GetReleaseByID(id int64) (*Release, error) { + r := new(Release) + has, err := x.Id(id).Get(r) + if err != nil { + return nil, err + } else if !has { + return nil, ErrReleaseNotExist{id, ""} + } + + return r, r.LoadAttributes() +} + +// GetPublishedReleasesByRepoID returns a list of published releases of repository. +// If matches is not empty, only published releases in matches will be returned. +// In any case, drafts won't be returned by this function. +func GetPublishedReleasesByRepoID(repoID int64, matches ...string) ([]*Release, error) { + sess := x.Where("repo_id = ?", repoID).And("is_draft = ?", false).Desc("created_unix") + if len(matches) > 0 { + sess.In("tag_name", matches) + } + releases := make([]*Release, 0, 5) + return releases, sess.Find(&releases, new(Release)) +} + +// GetDraftReleasesByRepoID returns all draft releases of repository. +func GetDraftReleasesByRepoID(repoID int64) ([]*Release, error) { + releases := make([]*Release, 0) + return releases, x.Where("repo_id = ?", repoID).And("is_draft = ?", true).Find(&releases) +} + +type ReleaseSorter struct { + releases []*Release +} + +func (rs *ReleaseSorter) Len() int { + return len(rs.releases) +} + +func (rs *ReleaseSorter) Less(i, j int) bool { + diffNum := rs.releases[i].NumCommits - rs.releases[j].NumCommits + if diffNum != 0 { + return diffNum > 0 + } + return rs.releases[i].Created.After(rs.releases[j].Created) +} + +func (rs *ReleaseSorter) Swap(i, j int) { + rs.releases[i], rs.releases[j] = rs.releases[j], rs.releases[i] +} + +// SortReleases sorts releases by number of commits and created time. +func SortReleases(rels []*Release) { + sorter := &ReleaseSorter{releases: rels} + sort.Sort(sorter) +} + +// UpdateRelease updates information of a release. +func UpdateRelease(doer *User, gitRepo *git.Repository, r *Release, isPublish bool, uuids []string) (err error) { + if err = createTag(gitRepo, r); err != nil { + return fmt.Errorf("createTag: %v", err) + } + + r.PublisherID = doer.ID + + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return err + } + if _, err = sess.ID(r.ID).AllCols().Update(r); err != nil { + return fmt.Errorf("Update: %v", err) + } + + // Unlink all current attachments and link back later if still valid + if _, err = sess.Exec("UPDATE attachment SET release_id = 0 WHERE release_id = ?", r.ID); err != nil { + return fmt.Errorf("unlink current attachments: %v", err) + } + + if len(uuids) > 0 { + if _, err = sess.In("uuid", uuids).Cols("release_id").Update(&Attachment{ReleaseID: r.ID}); err != nil { + return fmt.Errorf("link attachments: %v", err) + } + } + + if err = sess.Commit(); err != nil { + return fmt.Errorf("Commit: %v", err) + } + + if !isPublish { + return nil + } + r.Publisher = doer + r.preparePublishWebhooks() + return nil +} + +// DeleteReleaseOfRepoByID deletes a release and corresponding Git tag by given ID. +func DeleteReleaseOfRepoByID(repoID, id int64) error { + rel, err := GetReleaseByID(id) + if err != nil { + return fmt.Errorf("GetReleaseByID: %v", err) + } + + // Mark sure the delete operation againsts same repository. + if repoID != rel.RepoID { + return nil + } + + repo, err := GetRepositoryByID(rel.RepoID) + if err != nil { + return fmt.Errorf("GetRepositoryByID: %v", err) + } + + _, stderr, err := process.ExecDir(-1, repo.RepoPath(), + fmt.Sprintf("DeleteReleaseByID (git tag -d): %d", rel.ID), + "git", "tag", "-d", rel.TagName) + if err != nil && !strings.Contains(stderr, "not found") { + return fmt.Errorf("git tag -d: %v - %s", err, stderr) + } + + if _, err = x.Id(rel.ID).Delete(new(Release)); err != nil { + return fmt.Errorf("Delete: %v", err) + } + + return nil +} diff --git a/internal/db/repo.go b/internal/db/repo.go new file mode 100644 index 00000000..8e839ef6 --- /dev/null +++ b/internal/db/repo.go @@ -0,0 +1,2458 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package db + +import ( + "bytes" + "fmt" + "image" + _ "image/jpeg" + "image/png" + "io/ioutil" + "os" + "os/exec" + "path" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/mcuadros/go-version" + "github.com/nfnt/resize" + "github.com/unknwon/cae/zip" + "github.com/unknwon/com" + log "gopkg.in/clog.v1" + "gopkg.in/ini.v1" + "xorm.io/xorm" + + "github.com/gogs/git-module" + api "github.com/gogs/go-gogs-client" + + "gogs.io/gogs/internal/avatar" + "gogs.io/gogs/internal/bindata" + "gogs.io/gogs/internal/db/errors" + "gogs.io/gogs/internal/markup" + "gogs.io/gogs/internal/process" + "gogs.io/gogs/internal/setting" + "gogs.io/gogs/internal/sync" +) + +// REPO_AVATAR_URL_PREFIX is used to identify a URL is to access repository avatar. +const REPO_AVATAR_URL_PREFIX = "repo-avatars" + +var repoWorkingPool = sync.NewExclusivePool() + +var ( + Gitignores, Licenses, Readmes, LabelTemplates []string + + // Maximum items per page in forks, watchers and stars of a repo + ItemsPerPage = 40 +) + +func LoadRepoConfig() { + // Load .gitignore and license files and readme templates. + types := []string{"gitignore", "license", "readme", "label"} + typeFiles := make([][]string, 4) + for i, t := range types { + files, err := bindata.AssetDir("conf/" + t) + if err != nil { + log.Fatal(4, "Fail to get %s files: %v", t, err) + } + customPath := path.Join(setting.CustomPath, "conf", t) + if com.IsDir(customPath) { + customFiles, err := com.StatDir(customPath) + if err != nil { + log.Fatal(4, "Fail to get custom %s files: %v", t, err) + } + + for _, f := range customFiles { + if !com.IsSliceContainsStr(files, f) { + files = append(files, f) + } + } + } + typeFiles[i] = files + } + + Gitignores = typeFiles[0] + Licenses = typeFiles[1] + Readmes = typeFiles[2] + LabelTemplates = typeFiles[3] + sort.Strings(Gitignores) + sort.Strings(Licenses) + sort.Strings(Readmes) + sort.Strings(LabelTemplates) + + // Filter out invalid names and promote preferred licenses. + sortedLicenses := make([]string, 0, len(Licenses)) + for _, name := range setting.Repository.PreferredLicenses { + if com.IsSliceContainsStr(Licenses, name) { + sortedLicenses = append(sortedLicenses, name) + } + } + for _, name := range Licenses { + if !com.IsSliceContainsStr(setting.Repository.PreferredLicenses, name) { + sortedLicenses = append(sortedLicenses, name) + } + } + Licenses = sortedLicenses +} + +func NewRepoContext() { + zip.Verbose = false + + // Check Git installation. + if _, err := exec.LookPath("git"); err != nil { + log.Fatal(4, "Fail to test 'git' command: %v (forgotten install?)", err) + } + + // Check Git version. + var err error + setting.Git.Version, err = git.BinVersion() + if err != nil { + log.Fatal(4, "Fail to get Git version: %v", err) + } + + log.Info("Git Version: %s", setting.Git.Version) + if version.Compare("1.7.1", setting.Git.Version, ">") { + log.Fatal(4, "Gogs requires Git version greater or equal to 1.7.1") + } + git.HookDir = "custom_hooks" + git.HookSampleDir = "hooks" + git.DefaultCommitsPageSize = setting.UI.User.CommitsPagingNum + + // Git requires setting user.name and user.email in order to commit changes. + for configKey, defaultValue := range map[string]string{"user.name": "Gogs", "user.email": "gogs@fake.local"} { + if stdout, stderr, err := process.Exec("NewRepoContext(get setting)", "git", "config", "--get", configKey); err != nil || strings.TrimSpace(stdout) == "" { + // ExitError indicates this config is not set + if _, ok := err.(*exec.ExitError); ok || strings.TrimSpace(stdout) == "" { + if _, stderr, gerr := process.Exec("NewRepoContext(set "+configKey+")", "git", "config", "--global", configKey, defaultValue); gerr != nil { + log.Fatal(4, "Fail to set git %s(%s): %s", configKey, gerr, stderr) + } + log.Info("Git config %s set to %s", configKey, defaultValue) + } else { + log.Fatal(4, "Fail to get git %s(%s): %s", configKey, err, stderr) + } + } + } + + // Set git some configurations. + if _, stderr, err := process.Exec("NewRepoContext(git config --global core.quotepath false)", + "git", "config", "--global", "core.quotepath", "false"); err != nil { + log.Fatal(4, "Fail to execute 'git config --global core.quotepath false': %s", stderr) + } + + RemoveAllWithNotice("Clean up repository temporary data", filepath.Join(setting.AppDataPath, "tmp")) +} + +// Repository contains information of a repository. +type Repository struct { + ID int64 + OwnerID int64 `xorm:"UNIQUE(s)"` + Owner *User `xorm:"-" json:"-"` + LowerName string `xorm:"UNIQUE(s) INDEX NOT NULL"` + Name string `xorm:"INDEX NOT NULL"` + Description string `xorm:"VARCHAR(512)"` + Website string + DefaultBranch string + Size int64 `xorm:"NOT NULL DEFAULT 0"` + UseCustomAvatar bool + + // Counters + NumWatches int + NumStars int + NumForks int + NumIssues int + NumClosedIssues int + NumOpenIssues int `xorm:"-" json:"-"` + NumPulls int + NumClosedPulls int + NumOpenPulls int `xorm:"-" json:"-"` + NumMilestones int `xorm:"NOT NULL DEFAULT 0"` + NumClosedMilestones int `xorm:"NOT NULL DEFAULT 0"` + NumOpenMilestones int `xorm:"-" json:"-"` + NumTags int `xorm:"-" json:"-"` + + IsPrivate bool + IsBare bool + + IsMirror bool + *Mirror `xorm:"-" json:"-"` + + // Advanced settings + EnableWiki bool `xorm:"NOT NULL DEFAULT true"` + AllowPublicWiki bool + EnableExternalWiki bool + ExternalWikiURL string + EnableIssues bool `xorm:"NOT NULL DEFAULT true"` + AllowPublicIssues bool + EnableExternalTracker bool + ExternalTrackerURL string + ExternalTrackerFormat string + ExternalTrackerStyle string + ExternalMetas map[string]string `xorm:"-" json:"-"` + EnablePulls bool `xorm:"NOT NULL DEFAULT true"` + PullsIgnoreWhitespace bool `xorm:"NOT NULL DEFAULT false"` + PullsAllowRebase bool `xorm:"NOT NULL DEFAULT false"` + + IsFork bool `xorm:"NOT NULL DEFAULT false"` + ForkID int64 + BaseRepo *Repository `xorm:"-" json:"-"` + + Created time.Time `xorm:"-" json:"-"` + CreatedUnix int64 + Updated time.Time `xorm:"-" json:"-"` + UpdatedUnix int64 +} + +func (repo *Repository) BeforeInsert() { + repo.CreatedUnix = time.Now().Unix() + repo.UpdatedUnix = repo.CreatedUnix +} + +func (repo *Repository) BeforeUpdate() { + repo.UpdatedUnix = time.Now().Unix() +} + +func (repo *Repository) AfterSet(colName string, _ xorm.Cell) { + switch colName { + case "default_branch": + // FIXME: use db migration to solve all at once. + if len(repo.DefaultBranch) == 0 { + repo.DefaultBranch = "master" + } + case "num_closed_issues": + repo.NumOpenIssues = repo.NumIssues - repo.NumClosedIssues + case "num_closed_pulls": + repo.NumOpenPulls = repo.NumPulls - repo.NumClosedPulls + case "num_closed_milestones": + repo.NumOpenMilestones = repo.NumMilestones - repo.NumClosedMilestones + case "external_tracker_style": + if len(repo.ExternalTrackerStyle) == 0 { + repo.ExternalTrackerStyle = markup.ISSUE_NAME_STYLE_NUMERIC + } + case "created_unix": + repo.Created = time.Unix(repo.CreatedUnix, 0).Local() + case "updated_unix": + repo.Updated = time.Unix(repo.UpdatedUnix, 0) + } +} + +func (repo *Repository) loadAttributes(e Engine) (err error) { + if repo.Owner == nil { + repo.Owner, err = getUserByID(e, repo.OwnerID) + if err != nil { + return fmt.Errorf("getUserByID [%d]: %v", repo.OwnerID, err) + } + } + + if repo.IsFork && repo.BaseRepo == nil { + repo.BaseRepo, err = getRepositoryByID(e, repo.ForkID) + if err != nil { + if errors.IsRepoNotExist(err) { + repo.IsFork = false + repo.ForkID = 0 + } else { + return fmt.Errorf("getRepositoryByID [%d]: %v", repo.ForkID, err) + } + } + } + + return nil +} + +func (repo *Repository) LoadAttributes() error { + return repo.loadAttributes(x) +} + +// IsPartialPublic returns true if repository is public or allow public access to wiki or issues. +func (repo *Repository) IsPartialPublic() bool { + return !repo.IsPrivate || repo.AllowPublicWiki || repo.AllowPublicIssues +} + +func (repo *Repository) CanGuestViewWiki() bool { + return repo.EnableWiki && !repo.EnableExternalWiki && repo.AllowPublicWiki +} + +func (repo *Repository) CanGuestViewIssues() bool { + return repo.EnableIssues && !repo.EnableExternalTracker && repo.AllowPublicIssues +} + +// MustOwner always returns a valid *User object to avoid conceptually impossible error handling. +// It creates a fake object that contains error deftail when error occurs. +func (repo *Repository) MustOwner() *User { + return repo.mustOwner(x) +} + +func (repo *Repository) FullName() string { + return repo.MustOwner().Name + "/" + repo.Name +} + +func (repo *Repository) HTMLURL() string { + return setting.AppURL + repo.FullName() +} + +// CustomAvatarPath returns repository custom avatar file path. +func (repo *Repository) CustomAvatarPath() string { + return filepath.Join(setting.RepositoryAvatarUploadPath, com.ToStr(repo.ID)) +} + +// RelAvatarLink returns relative avatar link to the site domain, +// which includes app sub-url as prefix. +// Since Gravatar support not needed here - just check for image path. +func (repo *Repository) RelAvatarLink() string { + defaultImgUrl := "" + if !com.IsExist(repo.CustomAvatarPath()) { + return defaultImgUrl + } + return fmt.Sprintf("%s/%s/%d", setting.AppSubURL, REPO_AVATAR_URL_PREFIX, repo.ID) +} + +// AvatarLink returns repository avatar absolute link. +func (repo *Repository) AvatarLink() string { + link := repo.RelAvatarLink() + if link[0] == '/' && link[1] != '/' { + return setting.AppURL + strings.TrimPrefix(link, setting.AppSubURL)[1:] + } + return link +} + +// UploadAvatar saves custom avatar for repository. +// FIXME: split uploads to different subdirs in case we have massive number of repositories. +func (repo *Repository) UploadAvatar(data []byte) error { + img, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + return fmt.Errorf("decode image: %v", err) + } + + os.MkdirAll(setting.RepositoryAvatarUploadPath, os.ModePerm) + fw, err := os.Create(repo.CustomAvatarPath()) + if err != nil { + return fmt.Errorf("create custom avatar directory: %v", err) + } + defer fw.Close() + + m := resize.Resize(avatar.AVATAR_SIZE, avatar.AVATAR_SIZE, img, resize.NearestNeighbor) + if err = png.Encode(fw, m); err != nil { + return fmt.Errorf("encode image: %v", err) + } + + return nil +} + +// DeleteAvatar deletes the repository custom avatar. +func (repo *Repository) DeleteAvatar() error { + log.Trace("DeleteAvatar [%d]: %s", repo.ID, repo.CustomAvatarPath()) + if err := os.Remove(repo.CustomAvatarPath()); err != nil { + return err + } + + repo.UseCustomAvatar = false + return UpdateRepository(repo, false) +} + +// This method assumes following fields have been assigned with valid values: +// Required - BaseRepo (if fork) +// Arguments that are allowed to be nil: permission +func (repo *Repository) APIFormat(permission *api.Permission, user ...*User) *api.Repository { + cloneLink := repo.CloneLink() + apiRepo := &api.Repository{ + ID: repo.ID, + Owner: repo.Owner.APIFormat(), + Name: repo.Name, + FullName: repo.FullName(), + Description: repo.Description, + Private: repo.IsPrivate, + Fork: repo.IsFork, + Empty: repo.IsBare, + Mirror: repo.IsMirror, + Size: repo.Size, + HTMLURL: repo.HTMLURL(), + SSHURL: cloneLink.SSH, + CloneURL: cloneLink.HTTPS, + Website: repo.Website, + Stars: repo.NumStars, + Forks: repo.NumForks, + Watchers: repo.NumWatches, + OpenIssues: repo.NumOpenIssues, + DefaultBranch: repo.DefaultBranch, + Created: repo.Created, + Updated: repo.Updated, + Permissions: permission, + // Reserved for go-gogs-client change + // AvatarUrl: repo.AvatarLink(), + } + if repo.IsFork { + p := &api.Permission{Pull: true} + if len(user) != 0 { + p.Admin = user[0].IsAdminOfRepo(repo) + p.Push = user[0].IsWriterOfRepo(repo) + } + apiRepo.Parent = repo.BaseRepo.APIFormat(p) + } + return apiRepo +} + +func (repo *Repository) getOwner(e Engine) (err error) { + if repo.Owner != nil { + return nil + } + + repo.Owner, err = getUserByID(e, repo.OwnerID) + return err +} + +func (repo *Repository) GetOwner() error { + return repo.getOwner(x) +} + +func (repo *Repository) mustOwner(e Engine) *User { + if err := repo.getOwner(e); err != nil { + return &User{ + Name: "error", + FullName: err.Error(), + } + } + + return repo.Owner +} + +func (repo *Repository) UpdateSize() error { + countObject, err := git.GetRepoSize(repo.RepoPath()) + if err != nil { + return fmt.Errorf("GetRepoSize: %v", err) + } + + repo.Size = countObject.Size + countObject.SizePack + if _, err = x.Id(repo.ID).Cols("size").Update(repo); err != nil { + return fmt.Errorf("update size: %v", err) + } + return nil +} + +// ComposeMetas composes a map of metas for rendering external issue tracker URL. +func (repo *Repository) ComposeMetas() map[string]string { + if !repo.EnableExternalTracker { + return nil + } else if repo.ExternalMetas == nil { + repo.ExternalMetas = map[string]string{ + "format": repo.ExternalTrackerFormat, + "user": repo.MustOwner().Name, + "repo": repo.Name, + } + switch repo.ExternalTrackerStyle { + case markup.ISSUE_NAME_STYLE_ALPHANUMERIC: + repo.ExternalMetas["style"] = markup.ISSUE_NAME_STYLE_ALPHANUMERIC + default: + repo.ExternalMetas["style"] = markup.ISSUE_NAME_STYLE_NUMERIC + } + + } + return repo.ExternalMetas +} + +// DeleteWiki removes the actual and local copy of repository wiki. +func (repo *Repository) DeleteWiki() { + wikiPaths := []string{repo.WikiPath(), repo.LocalWikiPath()} + for _, wikiPath := range wikiPaths { + RemoveAllWithNotice("Delete repository wiki", wikiPath) + } +} + +// getUsersWithAccesMode returns users that have at least given access mode to the repository. +func (repo *Repository) getUsersWithAccesMode(e Engine, mode AccessMode) (_ []*User, err error) { + if err = repo.getOwner(e); err != nil { + return nil, err + } + + accesses := make([]*Access, 0, 10) + if err = e.Where("repo_id = ? AND mode >= ?", repo.ID, mode).Find(&accesses); err != nil { + return nil, err + } + + // Leave a seat for owner itself to append later, but if owner is an organization + // and just waste 1 unit is cheaper than re-allocate memory once. + users := make([]*User, 0, len(accesses)+1) + if len(accesses) > 0 { + userIDs := make([]int64, len(accesses)) + for i := 0; i < len(accesses); i++ { + userIDs[i] = accesses[i].UserID + } + + if err = e.In("id", userIDs).Find(&users); err != nil { + return nil, err + } + } + if !repo.Owner.IsOrganization() { + users = append(users, repo.Owner) + } + + return users, nil +} + +// getAssignees returns a list of users who can be assigned to issues in this repository. +func (repo *Repository) getAssignees(e Engine) (_ []*User, err error) { + return repo.getUsersWithAccesMode(e, ACCESS_MODE_READ) +} + +// GetAssignees returns all users that have read access and can be assigned to issues +// of the repository, +func (repo *Repository) GetAssignees() (_ []*User, err error) { + return repo.getAssignees(x) +} + +// GetAssigneeByID returns the user that has write access of repository by given ID. +func (repo *Repository) GetAssigneeByID(userID int64) (*User, error) { + return GetAssigneeByID(repo, userID) +} + +// GetWriters returns all users that have write access to the repository. +func (repo *Repository) GetWriters() (_ []*User, err error) { + return repo.getUsersWithAccesMode(x, ACCESS_MODE_WRITE) +} + +// GetMilestoneByID returns the milestone belongs to repository by given ID. +func (repo *Repository) GetMilestoneByID(milestoneID int64) (*Milestone, error) { + return GetMilestoneByRepoID(repo.ID, milestoneID) +} + +// IssueStats returns number of open and closed repository issues by given filter mode. +func (repo *Repository) IssueStats(userID int64, filterMode FilterMode, isPull bool) (int64, int64) { + return GetRepoIssueStats(repo.ID, userID, filterMode, isPull) +} + +func (repo *Repository) GetMirror() (err error) { + repo.Mirror, err = GetMirrorByRepoID(repo.ID) + return err +} + +func (repo *Repository) repoPath(e Engine) string { + return RepoPath(repo.mustOwner(e).Name, repo.Name) +} + +func (repo *Repository) RepoPath() string { + return repo.repoPath(x) +} + +func (repo *Repository) GitConfigPath() string { + return filepath.Join(repo.RepoPath(), "config") +} + +func (repo *Repository) RelLink() string { + return "/" + repo.FullName() +} + +func (repo *Repository) Link() string { + return setting.AppSubURL + "/" + repo.FullName() +} + +func (repo *Repository) ComposeCompareURL(oldCommitID, newCommitID string) string { + return fmt.Sprintf("%s/%s/compare/%s...%s", repo.MustOwner().Name, repo.Name, oldCommitID, newCommitID) +} + +func (repo *Repository) HasAccess(userID int64) bool { + has, _ := HasAccess(userID, repo, ACCESS_MODE_READ) + return has +} + +func (repo *Repository) IsOwnedBy(userID int64) bool { + return repo.OwnerID == userID +} + +// CanBeForked returns true if repository meets the requirements of being forked. +func (repo *Repository) CanBeForked() bool { + return !repo.IsBare +} + +// CanEnablePulls returns true if repository meets the requirements of accepting pulls. +func (repo *Repository) CanEnablePulls() bool { + return !repo.IsMirror && !repo.IsBare +} + +// AllowPulls returns true if repository meets the requirements of accepting pulls and has them enabled. +func (repo *Repository) AllowsPulls() bool { + return repo.CanEnablePulls() && repo.EnablePulls +} + +func (repo *Repository) IsBranchRequirePullRequest(name string) bool { + return IsBranchOfRepoRequirePullRequest(repo.ID, name) +} + +// CanEnableEditor returns true if repository meets the requirements of web editor. +func (repo *Repository) CanEnableEditor() bool { + return !repo.IsMirror +} + +// FIXME: should have a mutex to prevent producing same index for two issues that are created +// closely enough. +func (repo *Repository) NextIssueIndex() int64 { + return int64(repo.NumIssues+repo.NumPulls) + 1 +} + +func (repo *Repository) LocalCopyPath() string { + return path.Join(setting.AppDataPath, "tmp/local-repo", com.ToStr(repo.ID)) +} + +// UpdateLocalCopy fetches latest changes of given branch from repoPath to localPath. +// It creates a new clone if local copy does not exist, but does not checks out to a +// specific branch if the local copy belongs to a wiki. +// For existing local copy, it checks out to target branch by default, and safe to +// assume subsequent operations are against target branch when caller has confidence +// about no race condition. +func UpdateLocalCopyBranch(repoPath, localPath, branch string, isWiki bool) (err error) { + if !com.IsExist(localPath) { + // Checkout to a specific branch fails when wiki is an empty repository. + if isWiki { + branch = "" + } + if err = git.Clone(repoPath, localPath, git.CloneRepoOptions{ + Timeout: time.Duration(setting.Git.Timeout.Clone) * time.Second, + Branch: branch, + }); err != nil { + return fmt.Errorf("git clone %s: %v", branch, err) + } + } else { + if err = git.Fetch(localPath, git.FetchRemoteOptions{ + Prune: true, + }); err != nil { + return fmt.Errorf("git fetch: %v", err) + } + if err = git.Checkout(localPath, git.CheckoutOptions{ + Branch: branch, + }); err != nil { + return fmt.Errorf("git checkout %s: %v", branch, err) + } + + // Reset to align with remote in case of force push. + if err = git.ResetHEAD(localPath, true, "origin/"+branch); err != nil { + return fmt.Errorf("git reset --hard origin/%s: %v", branch, err) + } + } + return nil +} + +// UpdateLocalCopyBranch makes sure local copy of repository in given branch is up-to-date. +func (repo *Repository) UpdateLocalCopyBranch(branch string) error { + return UpdateLocalCopyBranch(repo.RepoPath(), repo.LocalCopyPath(), branch, false) +} + +// PatchPath returns corresponding patch file path of repository by given issue ID. +func (repo *Repository) PatchPath(index int64) (string, error) { + if err := repo.GetOwner(); err != nil { + return "", err + } + + return filepath.Join(RepoPath(repo.Owner.Name, repo.Name), "pulls", com.ToStr(index)+".patch"), nil +} + +// SavePatch saves patch data to corresponding location by given issue ID. +func (repo *Repository) SavePatch(index int64, patch []byte) error { + patchPath, err := repo.PatchPath(index) + if err != nil { + return fmt.Errorf("PatchPath: %v", err) + } + + os.MkdirAll(filepath.Dir(patchPath), os.ModePerm) + if err = ioutil.WriteFile(patchPath, patch, 0644); err != nil { + return fmt.Errorf("WriteFile: %v", err) + } + + return nil +} + +func isRepositoryExist(e Engine, u *User, repoName string) (bool, error) { + has, err := e.Get(&Repository{ + OwnerID: u.ID, + LowerName: strings.ToLower(repoName), + }) + return has && com.IsDir(RepoPath(u.Name, repoName)), err +} + +// IsRepositoryExist returns true if the repository with given name under user has already existed. +func IsRepositoryExist(u *User, repoName string) (bool, error) { + return isRepositoryExist(x, u, repoName) +} + +// CloneLink represents different types of clone URLs of repository. +type CloneLink struct { + SSH string + HTTPS string + Git string +} + +// ComposeHTTPSCloneURL returns HTTPS clone URL based on given owner and repository name. +func ComposeHTTPSCloneURL(owner, repo string) string { + return fmt.Sprintf("%s%s/%s.git", setting.AppURL, owner, repo) +} + +func (repo *Repository) cloneLink(isWiki bool) *CloneLink { + repoName := repo.Name + if isWiki { + repoName += ".wiki" + } + + repo.Owner = repo.MustOwner() + cl := new(CloneLink) + if setting.SSH.Port != 22 { + cl.SSH = fmt.Sprintf("ssh://%s@%s:%d/%s/%s.git", setting.RunUser, setting.SSH.Domain, setting.SSH.Port, repo.Owner.Name, repoName) + } else { + cl.SSH = fmt.Sprintf("%s@%s:%s/%s.git", setting.RunUser, setting.SSH.Domain, repo.Owner.Name, repoName) + } + cl.HTTPS = ComposeHTTPSCloneURL(repo.Owner.Name, repoName) + return cl +} + +// CloneLink returns clone URLs of repository. +func (repo *Repository) CloneLink() (cl *CloneLink) { + return repo.cloneLink(false) +} + +type MigrateRepoOptions struct { + Name string + Description string + IsPrivate bool + IsMirror bool + RemoteAddr string +} + +/* + GitHub, GitLab, Gogs: *.wiki.git + BitBucket: *.git/wiki +*/ +var commonWikiURLSuffixes = []string{".wiki.git", ".git/wiki"} + +// wikiRemoteURL returns accessible repository URL for wiki if exists. +// Otherwise, it returns an empty string. +func wikiRemoteURL(remote string) string { + remote = strings.TrimSuffix(remote, ".git") + for _, suffix := range commonWikiURLSuffixes { + wikiURL := remote + suffix + if git.IsRepoURLAccessible(git.NetworkOptions{ + URL: wikiURL, + }) { + return wikiURL + } + } + return "" +} + +// MigrateRepository migrates a existing repository from other project hosting. +func MigrateRepository(doer, owner *User, opts MigrateRepoOptions) (*Repository, error) { + repo, err := CreateRepository(doer, owner, CreateRepoOptions{ + Name: opts.Name, + Description: opts.Description, + IsPrivate: opts.IsPrivate, + IsMirror: opts.IsMirror, + }) + if err != nil { + return nil, err + } + + repoPath := RepoPath(owner.Name, opts.Name) + wikiPath := WikiPath(owner.Name, opts.Name) + + if owner.IsOrganization() { + t, err := owner.GetOwnerTeam() + if err != nil { + return nil, err + } + repo.NumWatches = t.NumMembers + } else { + repo.NumWatches = 1 + } + + migrateTimeout := time.Duration(setting.Git.Timeout.Migrate) * time.Second + + RemoveAllWithNotice("Repository path erase before creation", repoPath) + if err = git.Clone(opts.RemoteAddr, repoPath, git.CloneRepoOptions{ + Mirror: true, + Quiet: true, + Timeout: migrateTimeout, + }); err != nil { + return repo, fmt.Errorf("Clone: %v", err) + } + + wikiRemotePath := wikiRemoteURL(opts.RemoteAddr) + if len(wikiRemotePath) > 0 { + RemoveAllWithNotice("Repository wiki path erase before creation", wikiPath) + if err = git.Clone(wikiRemotePath, wikiPath, git.CloneRepoOptions{ + Mirror: true, + Quiet: true, + Timeout: migrateTimeout, + }); err != nil { + log.Trace("Fail to clone wiki: %v", err) + RemoveAllWithNotice("Delete repository wiki for initialization failure", wikiPath) + } + } + + // Check if repository is empty. + _, stderr, err := com.ExecCmdDir(repoPath, "git", "log", "-1") + if err != nil { + if strings.Contains(stderr, "fatal: bad default revision 'HEAD'") { + repo.IsBare = true + } else { + return repo, fmt.Errorf("check bare: %v - %s", err, stderr) + } + } + + if !repo.IsBare { + // Try to get HEAD branch and set it as default branch. + gitRepo, err := git.OpenRepository(repoPath) + if err != nil { + return repo, fmt.Errorf("OpenRepository: %v", err) + } + headBranch, err := gitRepo.GetHEADBranch() + if err != nil { + return repo, fmt.Errorf("GetHEADBranch: %v", err) + } + if headBranch != nil { + repo.DefaultBranch = headBranch.Name + } + + if err = repo.UpdateSize(); err != nil { + log.Error(2, "UpdateSize [repo_id: %d]: %v", repo.ID, err) + } + } + + if opts.IsMirror { + if _, err = x.InsertOne(&Mirror{ + RepoID: repo.ID, + Interval: setting.Mirror.DefaultInterval, + EnablePrune: true, + NextSync: time.Now().Add(time.Duration(setting.Mirror.DefaultInterval) * time.Hour), + }); err != nil { + return repo, fmt.Errorf("InsertOne: %v", err) + } + + repo.IsMirror = true + return repo, UpdateRepository(repo, false) + } + + return CleanUpMigrateInfo(repo) +} + +// cleanUpMigrateGitConfig removes mirror info which prevents "push --all". +// This also removes possible user credentials. +func cleanUpMigrateGitConfig(configPath string) error { + cfg, err := ini.Load(configPath) + if err != nil { + return fmt.Errorf("open config file: %v", err) + } + cfg.DeleteSection("remote \"origin\"") + if err = cfg.SaveToIndent(configPath, "\t"); err != nil { + return fmt.Errorf("save config file: %v", err) + } + return nil +} + +var hooksTpls = map[string]string{ + "pre-receive": "#!/usr/bin/env %s\n\"%s\" hook --config='%s' pre-receive\n", + "update": "#!/usr/bin/env %s\n\"%s\" hook --config='%s' update $1 $2 $3\n", + "post-receive": "#!/usr/bin/env %s\n\"%s\" hook --config='%s' post-receive\n", +} + +func createDelegateHooks(repoPath string) (err error) { + for _, name := range git.HookNames { + hookPath := filepath.Join(repoPath, "hooks", name) + if err = ioutil.WriteFile(hookPath, + []byte(fmt.Sprintf(hooksTpls[name], setting.ScriptType, setting.AppPath, setting.CustomConf)), + os.ModePerm); err != nil { + return fmt.Errorf("create delegate hook '%s': %v", hookPath, err) + } + } + return nil +} + +// Finish migrating repository and/or wiki with things that don't need to be done for mirrors. +func CleanUpMigrateInfo(repo *Repository) (*Repository, error) { + repoPath := repo.RepoPath() + if err := createDelegateHooks(repoPath); err != nil { + return repo, fmt.Errorf("createDelegateHooks: %v", err) + } + if repo.HasWiki() { + if err := createDelegateHooks(repo.WikiPath()); err != nil { + return repo, fmt.Errorf("createDelegateHooks.(wiki): %v", err) + } + } + + if err := cleanUpMigrateGitConfig(repo.GitConfigPath()); err != nil { + return repo, fmt.Errorf("cleanUpMigrateGitConfig: %v", err) + } + if repo.HasWiki() { + if err := cleanUpMigrateGitConfig(path.Join(repo.WikiPath(), "config")); err != nil { + return repo, fmt.Errorf("cleanUpMigrateGitConfig.(wiki): %v", err) + } + } + + return repo, UpdateRepository(repo, false) +} + +// initRepoCommit temporarily changes with work directory. +func initRepoCommit(tmpPath string, sig *git.Signature) (err error) { + var stderr string + if _, stderr, err = process.ExecDir(-1, + tmpPath, fmt.Sprintf("initRepoCommit (git add): %s", tmpPath), + "git", "add", "--all"); err != nil { + return fmt.Errorf("git add: %s", stderr) + } + + if _, stderr, err = process.ExecDir(-1, + tmpPath, fmt.Sprintf("initRepoCommit (git commit): %s", tmpPath), + "git", "commit", fmt.Sprintf("--author='%s <%s>'", sig.Name, sig.Email), + "-m", "Initial commit"); err != nil { + return fmt.Errorf("git commit: %s", stderr) + } + + if _, stderr, err = process.ExecDir(-1, + tmpPath, fmt.Sprintf("initRepoCommit (git push): %s", tmpPath), + "git", "push", "origin", "master"); err != nil { + return fmt.Errorf("git push: %s", stderr) + } + return nil +} + +type CreateRepoOptions struct { + Name string + Description string + Gitignores string + License string + Readme string + IsPrivate bool + IsMirror bool + AutoInit bool +} + +func getRepoInitFile(tp, name string) ([]byte, error) { + relPath := path.Join("conf", tp, strings.TrimLeft(path.Clean("/"+name), "/")) + + // Use custom file when available. + customPath := path.Join(setting.CustomPath, relPath) + if com.IsFile(customPath) { + return ioutil.ReadFile(customPath) + } + return bindata.Asset(relPath) +} + +func prepareRepoCommit(repo *Repository, tmpDir, repoPath string, opts CreateRepoOptions) error { + // Clone to temprory path and do the init commit. + _, stderr, err := process.Exec( + fmt.Sprintf("initRepository(git clone): %s", repoPath), "git", "clone", repoPath, tmpDir) + if err != nil { + return fmt.Errorf("git clone: %v - %s", err, stderr) + } + + // README + data, err := getRepoInitFile("readme", opts.Readme) + if err != nil { + return fmt.Errorf("getRepoInitFile[%s]: %v", opts.Readme, err) + } + + cloneLink := repo.CloneLink() + match := map[string]string{ + "Name": repo.Name, + "Description": repo.Description, + "CloneURL.SSH": cloneLink.SSH, + "CloneURL.HTTPS": cloneLink.HTTPS, + } + if err = ioutil.WriteFile(filepath.Join(tmpDir, "README.md"), + []byte(com.Expand(string(data), match)), 0644); err != nil { + return fmt.Errorf("write README.md: %v", err) + } + + // .gitignore + if len(opts.Gitignores) > 0 { + var buf bytes.Buffer + names := strings.Split(opts.Gitignores, ",") + for _, name := range names { + data, err = getRepoInitFile("gitignore", name) + if err != nil { + return fmt.Errorf("getRepoInitFile[%s]: %v", name, err) + } + buf.WriteString("# ---> " + name + "\n") + buf.Write(data) + buf.WriteString("\n") + } + + if buf.Len() > 0 { + if err = ioutil.WriteFile(filepath.Join(tmpDir, ".gitignore"), buf.Bytes(), 0644); err != nil { + return fmt.Errorf("write .gitignore: %v", err) + } + } + } + + // LICENSE + if len(opts.License) > 0 { + data, err = getRepoInitFile("license", opts.License) + if err != nil { + return fmt.Errorf("getRepoInitFile[%s]: %v", opts.License, err) + } + + if err = ioutil.WriteFile(filepath.Join(tmpDir, "LICENSE"), data, 0644); err != nil { + return fmt.Errorf("write LICENSE: %v", err) + } + } + + return nil +} + +// initRepository performs initial commit with chosen setup files on behave of doer. +func initRepository(e Engine, repoPath string, doer *User, repo *Repository, opts CreateRepoOptions) (err error) { + // Somehow the directory could exist. + if com.IsExist(repoPath) { + return fmt.Errorf("initRepository: path already exists: %s", repoPath) + } + + // Init bare new repository. + if err = git.InitRepository(repoPath, true); err != nil { + return fmt.Errorf("InitRepository: %v", err) + } else if err = createDelegateHooks(repoPath); err != nil { + return fmt.Errorf("createDelegateHooks: %v", err) + } + + tmpDir := filepath.Join(os.TempDir(), "gogs-"+repo.Name+"-"+com.ToStr(time.Now().Nanosecond())) + + // Initialize repository according to user's choice. + if opts.AutoInit { + os.MkdirAll(tmpDir, os.ModePerm) + defer RemoveAllWithNotice("Delete repository for auto-initialization", tmpDir) + + if err = prepareRepoCommit(repo, tmpDir, repoPath, opts); err != nil { + return fmt.Errorf("prepareRepoCommit: %v", err) + } + + // Apply changes and commit. + if err = initRepoCommit(tmpDir, doer.NewGitSig()); err != nil { + return fmt.Errorf("initRepoCommit: %v", err) + } + } + + // Re-fetch the repository from database before updating it (else it would + // override changes that were done earlier with sql) + if repo, err = getRepositoryByID(e, repo.ID); err != nil { + return fmt.Errorf("getRepositoryByID: %v", err) + } + + if !opts.AutoInit { + repo.IsBare = true + } + + repo.DefaultBranch = "master" + if err = updateRepository(e, repo, false); err != nil { + return fmt.Errorf("updateRepository: %v", err) + } + + return nil +} + +var ( + reservedRepoNames = []string{".", ".."} + reservedRepoPatterns = []string{"*.git", "*.wiki"} +) + +// IsUsableRepoName return an error if given name is a reserved name or pattern. +func IsUsableRepoName(name string) error { + return isUsableName(reservedRepoNames, reservedRepoPatterns, name) +} + +func createRepository(e *xorm.Session, doer, owner *User, repo *Repository) (err error) { + if err = IsUsableRepoName(repo.Name); err != nil { + return err + } + + has, err := isRepositoryExist(e, owner, repo.Name) + if err != nil { + return fmt.Errorf("IsRepositoryExist: %v", err) + } else if has { + return ErrRepoAlreadyExist{owner.Name, repo.Name} + } + + if _, err = e.Insert(repo); err != nil { + return err + } + + owner.NumRepos++ + // Remember visibility preference. + owner.LastRepoVisibility = repo.IsPrivate + if err = updateUser(e, owner); err != nil { + return fmt.Errorf("updateUser: %v", err) + } + + // Give access to all members in owner team. + if owner.IsOrganization() { + t, err := owner.getOwnerTeam(e) + if err != nil { + return fmt.Errorf("getOwnerTeam: %v", err) + } else if err = t.addRepository(e, repo); err != nil { + return fmt.Errorf("addRepository: %v", err) + } + } else { + // Organization automatically called this in addRepository method. + if err = repo.recalculateAccesses(e); err != nil { + return fmt.Errorf("recalculateAccesses: %v", err) + } + } + + if err = watchRepo(e, owner.ID, repo.ID, true); err != nil { + return fmt.Errorf("watchRepo: %v", err) + } else if err = newRepoAction(e, doer, owner, repo); err != nil { + return fmt.Errorf("newRepoAction: %v", err) + } + + return repo.loadAttributes(e) +} + +// CreateRepository creates a repository for given user or organization. +func CreateRepository(doer, owner *User, opts CreateRepoOptions) (_ *Repository, err error) { + if !owner.CanCreateRepo() { + return nil, errors.ReachLimitOfRepo{owner.RepoCreationNum()} + } + + repo := &Repository{ + OwnerID: owner.ID, + Owner: owner, + Name: opts.Name, + LowerName: strings.ToLower(opts.Name), + Description: opts.Description, + IsPrivate: opts.IsPrivate, + EnableWiki: true, + EnableIssues: true, + EnablePulls: true, + } + + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return nil, err + } + + if err = createRepository(sess, doer, owner, repo); err != nil { + return nil, err + } + + // No need for init mirror. + if !opts.IsMirror { + repoPath := RepoPath(owner.Name, repo.Name) + if err = initRepository(sess, repoPath, doer, repo, opts); err != nil { + RemoveAllWithNotice("Delete repository for initialization failure", repoPath) + return nil, fmt.Errorf("initRepository: %v", err) + } + + _, stderr, err := process.ExecDir(-1, + repoPath, fmt.Sprintf("CreateRepository 'git update-server-info': %s", repoPath), + "git", "update-server-info") + if err != nil { + return nil, fmt.Errorf("CreateRepository 'git update-server-info': %s", stderr) + } + } + + return repo, sess.Commit() +} + +func countRepositories(userID int64, private bool) int64 { + sess := x.Where("id > 0") + + if userID > 0 { + sess.And("owner_id = ?", userID) + } + if !private { + sess.And("is_private=?", false) + } + + count, err := sess.Count(new(Repository)) + if err != nil { + log.Error(4, "countRepositories: %v", err) + } + return count +} + +// CountRepositories returns number of repositories. +// Argument private only takes effect when it is false, +// set it true to count all repositories. +func CountRepositories(private bool) int64 { + return countRepositories(-1, private) +} + +// CountUserRepositories returns number of repositories user owns. +// Argument private only takes effect when it is false, +// set it true to count all repositories. +func CountUserRepositories(userID int64, private bool) int64 { + return countRepositories(userID, private) +} + +func Repositories(page, pageSize int) (_ []*Repository, err error) { + repos := make([]*Repository, 0, pageSize) + return repos, x.Limit(pageSize, (page-1)*pageSize).Asc("id").Find(&repos) +} + +// RepositoriesWithUsers returns number of repos in given page. +func RepositoriesWithUsers(page, pageSize int) (_ []*Repository, err error) { + repos, err := Repositories(page, pageSize) + if err != nil { + return nil, fmt.Errorf("Repositories: %v", err) + } + + for i := range repos { + if err = repos[i].GetOwner(); err != nil { + return nil, err + } + } + + return repos, nil +} + +// FilterRepositoryWithIssues selects repositories that are using interal issue tracker +// and has disabled external tracker from given set. +// It returns nil if result set is empty. +func FilterRepositoryWithIssues(repoIDs []int64) ([]int64, error) { + if len(repoIDs) == 0 { + return nil, nil + } + + repos := make([]*Repository, 0, len(repoIDs)) + if err := x.Where("enable_issues=?", true). + And("enable_external_tracker=?", false). + In("id", repoIDs). + Cols("id"). + Find(&repos); err != nil { + return nil, fmt.Errorf("filter valid repositories %v: %v", repoIDs, err) + } + + if len(repos) == 0 { + return nil, nil + } + + repoIDs = make([]int64, len(repos)) + for i := range repos { + repoIDs[i] = repos[i].ID + } + return repoIDs, nil +} + +// RepoPath returns repository path by given user and repository name. +func RepoPath(userName, repoName string) string { + return filepath.Join(UserPath(userName), strings.ToLower(repoName)+".git") +} + +// TransferOwnership transfers all corresponding setting from old user to new one. +func TransferOwnership(doer *User, newOwnerName string, repo *Repository) error { + newOwner, err := GetUserByName(newOwnerName) + if err != nil { + return fmt.Errorf("get new owner '%s': %v", newOwnerName, err) + } + + // Check if new owner has repository with same name. + has, err := IsRepositoryExist(newOwner, repo.Name) + if err != nil { + return fmt.Errorf("IsRepositoryExist: %v", err) + } else if has { + return ErrRepoAlreadyExist{newOwnerName, repo.Name} + } + + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return fmt.Errorf("sess.Begin: %v", err) + } + + owner := repo.Owner + + // Note: we have to set value here to make sure recalculate accesses is based on + // new owner. + repo.OwnerID = newOwner.ID + repo.Owner = newOwner + + // Update repository. + if _, err := sess.ID(repo.ID).Update(repo); err != nil { + return fmt.Errorf("update owner: %v", err) + } + + // Remove redundant collaborators. + collaborators, err := repo.getCollaborators(sess) + if err != nil { + return fmt.Errorf("getCollaborators: %v", err) + } + + // Dummy object. + collaboration := &Collaboration{RepoID: repo.ID} + for _, c := range collaborators { + collaboration.UserID = c.ID + if c.ID == newOwner.ID || newOwner.IsOrgMember(c.ID) { + if _, err = sess.Delete(collaboration); err != nil { + return fmt.Errorf("remove collaborator '%d': %v", c.ID, err) + } + } + } + + // Remove old team-repository relations. + if owner.IsOrganization() { + if err = owner.getTeams(sess); err != nil { + return fmt.Errorf("getTeams: %v", err) + } + for _, t := range owner.Teams { + if !t.hasRepository(sess, repo.ID) { + continue + } + + t.NumRepos-- + if _, err := sess.ID(t.ID).AllCols().Update(t); err != nil { + return fmt.Errorf("decrease team repository count '%d': %v", t.ID, err) + } + } + + if err = owner.removeOrgRepo(sess, repo.ID); err != nil { + return fmt.Errorf("removeOrgRepo: %v", err) + } + } + + if newOwner.IsOrganization() { + t, err := newOwner.getOwnerTeam(sess) + if err != nil { + return fmt.Errorf("getOwnerTeam: %v", err) + } else if err = t.addRepository(sess, repo); err != nil { + return fmt.Errorf("add to owner team: %v", err) + } + } else { + // Organization called this in addRepository method. + if err = repo.recalculateAccesses(sess); err != nil { + return fmt.Errorf("recalculateAccesses: %v", err) + } + } + + // Update repository count. + if _, err = sess.Exec("UPDATE `user` SET num_repos=num_repos+1 WHERE id=?", newOwner.ID); err != nil { + return fmt.Errorf("increase new owner repository count: %v", err) + } else if _, err = sess.Exec("UPDATE `user` SET num_repos=num_repos-1 WHERE id=?", owner.ID); err != nil { + return fmt.Errorf("decrease old owner repository count: %v", err) + } + + if err = watchRepo(sess, newOwner.ID, repo.ID, true); err != nil { + return fmt.Errorf("watchRepo: %v", err) + } else if err = transferRepoAction(sess, doer, owner, repo); err != nil { + return fmt.Errorf("transferRepoAction: %v", err) + } + + // Rename remote repository to new path and delete local copy. + os.MkdirAll(UserPath(newOwner.Name), os.ModePerm) + if err = os.Rename(RepoPath(owner.Name, repo.Name), RepoPath(newOwner.Name, repo.Name)); err != nil { + return fmt.Errorf("rename repository directory: %v", err) + } + RemoveAllWithNotice("Delete repository local copy", repo.LocalCopyPath()) + + // Rename remote wiki repository to new path and delete local copy. + wikiPath := WikiPath(owner.Name, repo.Name) + if com.IsExist(wikiPath) { + RemoveAllWithNotice("Delete repository wiki local copy", repo.LocalWikiPath()) + if err = os.Rename(wikiPath, WikiPath(newOwner.Name, repo.Name)); err != nil { + return fmt.Errorf("rename repository wiki: %v", err) + } + } + + return sess.Commit() +} + +// ChangeRepositoryName changes all corresponding setting from old repository name to new one. +func ChangeRepositoryName(u *User, oldRepoName, newRepoName string) (err error) { + oldRepoName = strings.ToLower(oldRepoName) + newRepoName = strings.ToLower(newRepoName) + if err = IsUsableRepoName(newRepoName); err != nil { + return err + } + + has, err := IsRepositoryExist(u, newRepoName) + if err != nil { + return fmt.Errorf("IsRepositoryExist: %v", err) + } else if has { + return ErrRepoAlreadyExist{u.Name, newRepoName} + } + + repo, err := GetRepositoryByName(u.ID, oldRepoName) + if err != nil { + return fmt.Errorf("GetRepositoryByName: %v", err) + } + + // Change repository directory name + if err = os.Rename(repo.RepoPath(), RepoPath(u.Name, newRepoName)); err != nil { + return fmt.Errorf("rename repository directory: %v", err) + } + + wikiPath := repo.WikiPath() + if com.IsExist(wikiPath) { + if err = os.Rename(wikiPath, WikiPath(u.Name, newRepoName)); err != nil { + return fmt.Errorf("rename repository wiki: %v", err) + } + RemoveAllWithNotice("Delete repository wiki local copy", repo.LocalWikiPath()) + } + + RemoveAllWithNotice("Delete repository local copy", repo.LocalCopyPath()) + return nil +} + +func getRepositoriesByForkID(e Engine, forkID int64) ([]*Repository, error) { + repos := make([]*Repository, 0, 10) + return repos, e.Where("fork_id=?", forkID).Find(&repos) +} + +// GetRepositoriesByForkID returns all repositories with given fork ID. +func GetRepositoriesByForkID(forkID int64) ([]*Repository, error) { + return getRepositoriesByForkID(x, forkID) +} + +func updateRepository(e Engine, repo *Repository, visibilityChanged bool) (err error) { + repo.LowerName = strings.ToLower(repo.Name) + + if len(repo.Description) > 512 { + repo.Description = repo.Description[:512] + } + if len(repo.Website) > 255 { + repo.Website = repo.Website[:255] + } + + if _, err = e.ID(repo.ID).AllCols().Update(repo); err != nil { + return fmt.Errorf("update: %v", err) + } + + if visibilityChanged { + if err = repo.getOwner(e); err != nil { + return fmt.Errorf("getOwner: %v", err) + } + if repo.Owner.IsOrganization() { + // Organization repository need to recalculate access table when visivility is changed + if err = repo.recalculateTeamAccesses(e, 0); err != nil { + return fmt.Errorf("recalculateTeamAccesses: %v", err) + } + } + + // Create/Remove git-daemon-export-ok for git-daemon + daemonExportFile := path.Join(repo.RepoPath(), "git-daemon-export-ok") + if repo.IsPrivate && com.IsExist(daemonExportFile) { + if err = os.Remove(daemonExportFile); err != nil { + log.Error(4, "Failed to remove %s: %v", daemonExportFile, err) + } + } else if !repo.IsPrivate && !com.IsExist(daemonExportFile) { + if f, err := os.Create(daemonExportFile); err != nil { + log.Error(4, "Failed to create %s: %v", daemonExportFile, err) + } else { + f.Close() + } + } + + forkRepos, err := getRepositoriesByForkID(e, repo.ID) + if err != nil { + return fmt.Errorf("getRepositoriesByForkID: %v", err) + } + for i := range forkRepos { + forkRepos[i].IsPrivate = repo.IsPrivate + if err = updateRepository(e, forkRepos[i], true); err != nil { + return fmt.Errorf("updateRepository[%d]: %v", forkRepos[i].ID, err) + } + } + + // Change visibility of generated actions + if _, err = e.Where("repo_id = ?", repo.ID).Cols("is_private").Update(&Action{IsPrivate: repo.IsPrivate}); err != nil { + return fmt.Errorf("change action visibility of repository: %v", err) + } + } + + return nil +} + +func UpdateRepository(repo *Repository, visibilityChanged bool) (err error) { + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return err + } + + if err = updateRepository(x, repo, visibilityChanged); err != nil { + return fmt.Errorf("updateRepository: %v", err) + } + + return sess.Commit() +} + +// DeleteRepository deletes a repository for a user or organization. +func DeleteRepository(uid, repoID int64) error { + repo := &Repository{ID: repoID, OwnerID: uid} + has, err := x.Get(repo) + if err != nil { + return err + } else if !has { + return errors.RepoNotExist{repoID, uid, ""} + } + + // In case is a organization. + org, err := GetUserByID(uid) + if err != nil { + return err + } + if org.IsOrganization() { + if err = org.GetTeams(); err != nil { + return err + } + } + + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return err + } + + if org.IsOrganization() { + for _, t := range org.Teams { + if !t.hasRepository(sess, repoID) { + continue + } else if err = t.removeRepository(sess, repo, false); err != nil { + return err + } + } + } + + if err = deleteBeans(sess, + &Repository{ID: repoID}, + &Access{RepoID: repo.ID}, + &Action{RepoID: repo.ID}, + &Watch{RepoID: repoID}, + &Star{RepoID: repoID}, + &Mirror{RepoID: repoID}, + &IssueUser{RepoID: repoID}, + &Milestone{RepoID: repoID}, + &Release{RepoID: repoID}, + &Collaboration{RepoID: repoID}, + &PullRequest{BaseRepoID: repoID}, + &ProtectBranch{RepoID: repoID}, + &ProtectBranchWhitelist{RepoID: repoID}, + &Webhook{RepoID: repoID}, + &HookTask{RepoID: repoID}, + ); err != nil { + return fmt.Errorf("deleteBeans: %v", err) + } + + // Delete comments and attachments. + issues := make([]*Issue, 0, 25) + attachmentPaths := make([]string, 0, len(issues)) + if err = sess.Where("repo_id=?", repoID).Find(&issues); err != nil { + return err + } + for i := range issues { + if _, err = sess.Delete(&Comment{IssueID: issues[i].ID}); err != nil { + return err + } + + attachments := make([]*Attachment, 0, 5) + if err = sess.Where("issue_id=?", issues[i].ID).Find(&attachments); err != nil { + return err + } + for j := range attachments { + attachmentPaths = append(attachmentPaths, attachments[j].LocalPath()) + } + + if _, err = sess.Delete(&Attachment{IssueID: issues[i].ID}); err != nil { + return err + } + } + + if _, err = sess.Delete(&Issue{RepoID: repoID}); err != nil { + return err + } + + if repo.IsFork { + if _, err = sess.Exec("UPDATE `repository` SET num_forks=num_forks-1 WHERE id=?", repo.ForkID); err != nil { + return fmt.Errorf("decrease fork count: %v", err) + } + } + + if _, err = sess.Exec("UPDATE `user` SET num_repos=num_repos-1 WHERE id=?", uid); err != nil { + return err + } + + if err = sess.Commit(); err != nil { + return fmt.Errorf("Commit: %v", err) + } + + // Remove repository files. + repoPath := repo.RepoPath() + RemoveAllWithNotice("Delete repository files", repoPath) + + repo.DeleteWiki() + + // Remove attachment files. + for i := range attachmentPaths { + RemoveAllWithNotice("Delete attachment", attachmentPaths[i]) + } + + if repo.NumForks > 0 { + if _, err = x.Exec("UPDATE `repository` SET fork_id=0,is_fork=? WHERE fork_id=?", false, repo.ID); err != nil { + log.Error(4, "reset 'fork_id' and 'is_fork': %v", err) + } + } + + return nil +} + +// GetRepositoryByRef returns a Repository specified by a GFM reference. +// See https://help.github.com/articles/writing-on-github#references for more information on the syntax. +func GetRepositoryByRef(ref string) (*Repository, error) { + n := strings.IndexByte(ref, byte('/')) + if n < 2 { + return nil, errors.InvalidRepoReference{ref} + } + + userName, repoName := ref[:n], ref[n+1:] + user, err := GetUserByName(userName) + if err != nil { + return nil, err + } + + return GetRepositoryByName(user.ID, repoName) +} + +// GetRepositoryByName returns the repository by given name under user if exists. +func GetRepositoryByName(ownerID int64, name string) (*Repository, error) { + repo := &Repository{ + OwnerID: ownerID, + LowerName: strings.ToLower(name), + } + has, err := x.Get(repo) + if err != nil { + return nil, err + } else if !has { + return nil, errors.RepoNotExist{0, ownerID, name} + } + return repo, repo.LoadAttributes() +} + +func getRepositoryByID(e Engine, id int64) (*Repository, error) { + repo := new(Repository) + has, err := e.ID(id).Get(repo) + if err != nil { + return nil, err + } else if !has { + return nil, errors.RepoNotExist{id, 0, ""} + } + return repo, repo.loadAttributes(e) +} + +// GetRepositoryByID returns the repository by given id if exists. +func GetRepositoryByID(id int64) (*Repository, error) { + return getRepositoryByID(x, id) +} + +type UserRepoOptions struct { + UserID int64 + Private bool + Page int + PageSize int +} + +// GetUserRepositories returns a list of repositories of given user. +func GetUserRepositories(opts *UserRepoOptions) ([]*Repository, error) { + sess := x.Where("owner_id=?", opts.UserID).Desc("updated_unix") + if !opts.Private { + sess.And("is_private=?", false) + } + + if opts.Page <= 0 { + opts.Page = 1 + } + sess.Limit(opts.PageSize, (opts.Page-1)*opts.PageSize) + + repos := make([]*Repository, 0, opts.PageSize) + return repos, sess.Find(&repos) +} + +// GetUserRepositories returns a list of mirror repositories of given user. +func GetUserMirrorRepositories(userID int64) ([]*Repository, error) { + repos := make([]*Repository, 0, 10) + return repos, x.Where("owner_id = ?", userID).And("is_mirror = ?", true).Find(&repos) +} + +// GetRecentUpdatedRepositories returns the list of repositories that are recently updated. +func GetRecentUpdatedRepositories(page, pageSize int) (repos []*Repository, err error) { + return repos, x.Limit(pageSize, (page-1)*pageSize). + Where("is_private=?", false).Limit(pageSize).Desc("updated_unix").Find(&repos) +} + +// GetUserAndCollaborativeRepositories returns list of repositories the user owns and collaborates. +func GetUserAndCollaborativeRepositories(userID int64) ([]*Repository, error) { + repos := make([]*Repository, 0, 10) + if err := x.Alias("repo"). + Join("INNER", "collaboration", "collaboration.repo_id = repo.id"). + Where("collaboration.user_id = ?", userID). + Find(&repos); err != nil { + return nil, fmt.Errorf("select collaborative repositories: %v", err) + } + + ownRepos := make([]*Repository, 0, 10) + if err := x.Where("owner_id = ?", userID).Find(&ownRepos); err != nil { + return nil, fmt.Errorf("select own repositories: %v", err) + } + + return append(repos, ownRepos...), nil +} + +func getRepositoryCount(e Engine, u *User) (int64, error) { + return x.Count(&Repository{OwnerID: u.ID}) +} + +// GetRepositoryCount returns the total number of repositories of user. +func GetRepositoryCount(u *User) (int64, error) { + return getRepositoryCount(x, u) +} + +type SearchRepoOptions struct { + Keyword string + OwnerID int64 + UserID int64 // When set results will contain all public/private repositories user has access to + OrderBy string + Private bool // Include private repositories in results + Page int + PageSize int // Can be smaller than or equal to setting.ExplorePagingNum +} + +// SearchRepositoryByName takes keyword and part of repository name to search, +// it returns results in given range and number of total results. +func SearchRepositoryByName(opts *SearchRepoOptions) (repos []*Repository, count int64, err error) { + if opts.Page <= 0 { + opts.Page = 1 + } + + repos = make([]*Repository, 0, opts.PageSize) + sess := x.Alias("repo") + // Attempt to find repositories that opts.UserID has access to, + // this does not include other people's private repositories even if opts.UserID is an admin. + if !opts.Private && opts.UserID > 0 { + sess.Join("LEFT", "access", "access.repo_id = repo.id"). + Where("repo.owner_id = ? OR access.user_id = ? OR repo.is_private = ? OR (repo.is_private = ? AND (repo.allow_public_wiki = ? OR repo.allow_public_issues = ?))", opts.UserID, opts.UserID, false, true, true, true) + } else { + // Only return public repositories if opts.Private is not set + if !opts.Private { + sess.And("repo.is_private = ? OR (repo.is_private = ? AND (repo.allow_public_wiki = ? OR repo.allow_public_issues = ?))", false, true, true, true) + } + } + if len(opts.Keyword) > 0 { + sess.And("repo.lower_name LIKE ? OR repo.description LIKE ?", "%"+strings.ToLower(opts.Keyword)+"%", "%"+strings.ToLower(opts.Keyword)+"%") + } + if opts.OwnerID > 0 { + sess.And("repo.owner_id = ?", opts.OwnerID) + } + + // We need all fields (repo.*) in final list but only ID (repo.id) is good enough for counting. + count, err = sess.Clone().Distinct("repo.id").Count(new(Repository)) + if err != nil { + return nil, 0, fmt.Errorf("Count: %v", err) + } + + if len(opts.OrderBy) > 0 { + sess.OrderBy("repo." + opts.OrderBy) + } + return repos, count, sess.Distinct("repo.*").Limit(opts.PageSize, (opts.Page-1)*opts.PageSize).Find(&repos) +} + +func DeleteOldRepositoryArchives() { + if taskStatusTable.IsRunning(_CLEAN_OLD_ARCHIVES) { + return + } + taskStatusTable.Start(_CLEAN_OLD_ARCHIVES) + defer taskStatusTable.Stop(_CLEAN_OLD_ARCHIVES) + + log.Trace("Doing: DeleteOldRepositoryArchives") + + formats := []string{"zip", "targz"} + oldestTime := time.Now().Add(-setting.Cron.RepoArchiveCleanup.OlderThan) + if err := x.Where("id > 0").Iterate(new(Repository), + func(idx int, bean interface{}) error { + repo := bean.(*Repository) + basePath := filepath.Join(repo.RepoPath(), "archives") + for _, format := range formats { + dirPath := filepath.Join(basePath, format) + if !com.IsDir(dirPath) { + continue + } + + dir, err := os.Open(dirPath) + if err != nil { + log.Error(3, "Fail to open directory '%s': %v", dirPath, err) + continue + } + + fis, err := dir.Readdir(0) + dir.Close() + if err != nil { + log.Error(3, "Fail to read directory '%s': %v", dirPath, err) + continue + } + + for _, fi := range fis { + if fi.IsDir() || fi.ModTime().After(oldestTime) { + continue + } + + archivePath := filepath.Join(dirPath, fi.Name()) + if err = os.Remove(archivePath); err != nil { + desc := fmt.Sprintf("Fail to health delete archive '%s': %v", archivePath, err) + log.Warn(desc) + if err = CreateRepositoryNotice(desc); err != nil { + log.Error(3, "CreateRepositoryNotice: %v", err) + } + } + } + } + + return nil + }); err != nil { + log.Error(2, "DeleteOldRepositoryArchives: %v", err) + } +} + +// DeleteRepositoryArchives deletes all repositories' archives. +func DeleteRepositoryArchives() error { + if taskStatusTable.IsRunning(_CLEAN_OLD_ARCHIVES) { + return nil + } + taskStatusTable.Start(_CLEAN_OLD_ARCHIVES) + defer taskStatusTable.Stop(_CLEAN_OLD_ARCHIVES) + + return x.Where("id > 0").Iterate(new(Repository), + func(idx int, bean interface{}) error { + repo := bean.(*Repository) + return os.RemoveAll(filepath.Join(repo.RepoPath(), "archives")) + }) +} + +func gatherMissingRepoRecords() ([]*Repository, error) { + repos := make([]*Repository, 0, 10) + if err := x.Where("id > 0").Iterate(new(Repository), + func(idx int, bean interface{}) error { + repo := bean.(*Repository) + if !com.IsDir(repo.RepoPath()) { + repos = append(repos, repo) + } + return nil + }); err != nil { + if err2 := CreateRepositoryNotice(fmt.Sprintf("gatherMissingRepoRecords: %v", err)); err2 != nil { + return nil, fmt.Errorf("CreateRepositoryNotice: %v", err) + } + } + return repos, nil +} + +// DeleteMissingRepositories deletes all repository records that lost Git files. +func DeleteMissingRepositories() error { + repos, err := gatherMissingRepoRecords() + if err != nil { + return fmt.Errorf("gatherMissingRepoRecords: %v", err) + } + + if len(repos) == 0 { + return nil + } + + for _, repo := range repos { + log.Trace("Deleting %d/%d...", repo.OwnerID, repo.ID) + if err := DeleteRepository(repo.OwnerID, repo.ID); err != nil { + if err2 := CreateRepositoryNotice(fmt.Sprintf("DeleteRepository [%d]: %v", repo.ID, err)); err2 != nil { + return fmt.Errorf("CreateRepositoryNotice: %v", err) + } + } + } + return nil +} + +// ReinitMissingRepositories reinitializes all repository records that lost Git files. +func ReinitMissingRepositories() error { + repos, err := gatherMissingRepoRecords() + if err != nil { + return fmt.Errorf("gatherMissingRepoRecords: %v", err) + } + + if len(repos) == 0 { + return nil + } + + for _, repo := range repos { + log.Trace("Initializing %d/%d...", repo.OwnerID, repo.ID) + if err := git.InitRepository(repo.RepoPath(), true); err != nil { + if err2 := CreateRepositoryNotice(fmt.Sprintf("InitRepository [%d]: %v", repo.ID, err)); err2 != nil { + return fmt.Errorf("CreateRepositoryNotice: %v", err) + } + } + } + return nil +} + +// SyncRepositoryHooks rewrites all repositories' pre-receive, update and post-receive hooks +// to make sure the binary and custom conf path are up-to-date. +func SyncRepositoryHooks() error { + return x.Where("id > 0").Iterate(new(Repository), + func(idx int, bean interface{}) error { + repo := bean.(*Repository) + if err := createDelegateHooks(repo.RepoPath()); err != nil { + return err + } + + if repo.HasWiki() { + return createDelegateHooks(repo.WikiPath()) + } + return nil + }) +} + +// Prevent duplicate running tasks. +var taskStatusTable = sync.NewStatusTable() + +const ( + _MIRROR_UPDATE = "mirror_update" + _GIT_FSCK = "git_fsck" + _CHECK_REPO_STATS = "check_repos_stats" + _CLEAN_OLD_ARCHIVES = "clean_old_archives" +) + +// GitFsck calls 'git fsck' to check repository health. +func GitFsck() { + if taskStatusTable.IsRunning(_GIT_FSCK) { + return + } + taskStatusTable.Start(_GIT_FSCK) + defer taskStatusTable.Stop(_GIT_FSCK) + + log.Trace("Doing: GitFsck") + + if err := x.Where("id>0").Iterate(new(Repository), + func(idx int, bean interface{}) error { + repo := bean.(*Repository) + repoPath := repo.RepoPath() + if err := git.Fsck(repoPath, setting.Cron.RepoHealthCheck.Timeout, setting.Cron.RepoHealthCheck.Args...); err != nil { + desc := fmt.Sprintf("Failed to perform health check on repository '%s': %v", repoPath, err) + log.Warn(desc) + if err = CreateRepositoryNotice(desc); err != nil { + log.Error(3, "CreateRepositoryNotice: %v", err) + } + } + return nil + }); err != nil { + log.Error(2, "GitFsck: %v", err) + } +} + +func GitGcRepos() error { + args := append([]string{"gc"}, setting.Git.GCArgs...) + return x.Where("id > 0").Iterate(new(Repository), + func(idx int, bean interface{}) error { + repo := bean.(*Repository) + if err := repo.GetOwner(); err != nil { + return err + } + _, stderr, err := process.ExecDir( + time.Duration(setting.Git.Timeout.GC)*time.Second, + RepoPath(repo.Owner.Name, repo.Name), "Repository garbage collection", + "git", args...) + if err != nil { + return fmt.Errorf("%v: %v", err, stderr) + } + return nil + }) +} + +type repoChecker struct { + querySQL, correctSQL string + desc string +} + +func repoStatsCheck(checker *repoChecker) { + results, err := x.Query(checker.querySQL) + if err != nil { + log.Error(2, "Select %s: %v", checker.desc, err) + return + } + for _, result := range results { + id := com.StrTo(result["id"]).MustInt64() + log.Trace("Updating %s: %d", checker.desc, id) + _, err = x.Exec(checker.correctSQL, id, id) + if err != nil { + log.Error(2, "Update %s[%d]: %v", checker.desc, id, err) + } + } +} + +func CheckRepoStats() { + if taskStatusTable.IsRunning(_CHECK_REPO_STATS) { + return + } + taskStatusTable.Start(_CHECK_REPO_STATS) + defer taskStatusTable.Stop(_CHECK_REPO_STATS) + + log.Trace("Doing: CheckRepoStats") + + checkers := []*repoChecker{ + // Repository.NumWatches + { + "SELECT repo.id FROM `repository` repo WHERE repo.num_watches!=(SELECT COUNT(*) FROM `watch` WHERE repo_id=repo.id)", + "UPDATE `repository` SET num_watches=(SELECT COUNT(*) FROM `watch` WHERE repo_id=?) WHERE id=?", + "repository count 'num_watches'", + }, + // Repository.NumStars + { + "SELECT repo.id FROM `repository` repo WHERE repo.num_stars!=(SELECT COUNT(*) FROM `star` WHERE repo_id=repo.id)", + "UPDATE `repository` SET num_stars=(SELECT COUNT(*) FROM `star` WHERE repo_id=?) WHERE id=?", + "repository count 'num_stars'", + }, + // Label.NumIssues + { + "SELECT label.id FROM `label` WHERE label.num_issues!=(SELECT COUNT(*) FROM `issue_label` WHERE label_id=label.id)", + "UPDATE `label` SET num_issues=(SELECT COUNT(*) FROM `issue_label` WHERE label_id=?) WHERE id=?", + "label count 'num_issues'", + }, + // User.NumRepos + { + "SELECT `user`.id FROM `user` WHERE `user`.num_repos!=(SELECT COUNT(*) FROM `repository` WHERE owner_id=`user`.id)", + "UPDATE `user` SET num_repos=(SELECT COUNT(*) FROM `repository` WHERE owner_id=?) WHERE id=?", + "user count 'num_repos'", + }, + // Issue.NumComments + { + "SELECT `issue`.id FROM `issue` WHERE `issue`.num_comments!=(SELECT COUNT(*) FROM `comment` WHERE issue_id=`issue`.id AND type=0)", + "UPDATE `issue` SET num_comments=(SELECT COUNT(*) FROM `comment` WHERE issue_id=? AND type=0) WHERE id=?", + "issue count 'num_comments'", + }, + } + for i := range checkers { + repoStatsCheck(checkers[i]) + } + + // ***** START: Repository.NumClosedIssues ***** + desc := "repository count 'num_closed_issues'" + results, err := x.Query("SELECT repo.id FROM `repository` repo WHERE repo.num_closed_issues!=(SELECT COUNT(*) FROM `issue` WHERE repo_id=repo.id AND is_closed=? AND is_pull=?)", true, false) + if err != nil { + log.Error(2, "Select %s: %v", desc, err) + } else { + for _, result := range results { + id := com.StrTo(result["id"]).MustInt64() + log.Trace("Updating %s: %d", desc, id) + _, err = x.Exec("UPDATE `repository` SET num_closed_issues=(SELECT COUNT(*) FROM `issue` WHERE repo_id=? AND is_closed=? AND is_pull=?) WHERE id=?", id, true, false, id) + if err != nil { + log.Error(2, "Update %s[%d]: %v", desc, id, err) + } + } + } + // ***** END: Repository.NumClosedIssues ***** + + // FIXME: use checker when stop supporting old fork repo format. + // ***** START: Repository.NumForks ***** + results, err = x.Query("SELECT repo.id FROM `repository` repo WHERE repo.num_forks!=(SELECT COUNT(*) FROM `repository` WHERE fork_id=repo.id)") + if err != nil { + log.Error(2, "Select repository count 'num_forks': %v", err) + } else { + for _, result := range results { + id := com.StrTo(result["id"]).MustInt64() + log.Trace("Updating repository count 'num_forks': %d", id) + + repo, err := GetRepositoryByID(id) + if err != nil { + log.Error(2, "GetRepositoryByID[%d]: %v", id, err) + continue + } + + rawResult, err := x.Query("SELECT COUNT(*) FROM `repository` WHERE fork_id=?", repo.ID) + if err != nil { + log.Error(2, "Select count of forks[%d]: %v", repo.ID, err) + continue + } + repo.NumForks = int(parseCountResult(rawResult)) + + if err = UpdateRepository(repo, false); err != nil { + log.Error(2, "UpdateRepository[%d]: %v", id, err) + continue + } + } + } + // ***** END: Repository.NumForks ***** +} + +type RepositoryList []*Repository + +func (repos RepositoryList) loadAttributes(e Engine) error { + if len(repos) == 0 { + return nil + } + + // Load owners + userSet := make(map[int64]*User) + for i := range repos { + userSet[repos[i].OwnerID] = nil + } + userIDs := make([]int64, 0, len(userSet)) + for userID := range userSet { + userIDs = append(userIDs, userID) + } + users := make([]*User, 0, len(userIDs)) + if err := e.Where("id > 0").In("id", userIDs).Find(&users); err != nil { + return fmt.Errorf("find users: %v", err) + } + for i := range users { + userSet[users[i].ID] = users[i] + } + for i := range repos { + repos[i].Owner = userSet[repos[i].OwnerID] + } + + // Load base repositories + repoSet := make(map[int64]*Repository) + for i := range repos { + if repos[i].IsFork { + repoSet[repos[i].ForkID] = nil + } + } + baseIDs := make([]int64, 0, len(repoSet)) + for baseID := range repoSet { + baseIDs = append(baseIDs, baseID) + } + baseRepos := make([]*Repository, 0, len(baseIDs)) + if err := e.Where("id > 0").In("id", baseIDs).Find(&baseRepos); err != nil { + return fmt.Errorf("find base repositories: %v", err) + } + for i := range baseRepos { + repoSet[baseRepos[i].ID] = baseRepos[i] + } + for i := range repos { + if repos[i].IsFork { + repos[i].BaseRepo = repoSet[repos[i].ForkID] + } + } + + return nil +} + +func (repos RepositoryList) LoadAttributes() error { + return repos.loadAttributes(x) +} + +type MirrorRepositoryList []*Repository + +func (repos MirrorRepositoryList) loadAttributes(e Engine) error { + if len(repos) == 0 { + return nil + } + + // Load mirrors. + repoIDs := make([]int64, 0, len(repos)) + for i := range repos { + if !repos[i].IsMirror { + continue + } + + repoIDs = append(repoIDs, repos[i].ID) + } + mirrors := make([]*Mirror, 0, len(repoIDs)) + if err := e.Where("id > 0").In("repo_id", repoIDs).Find(&mirrors); err != nil { + return fmt.Errorf("find mirrors: %v", err) + } + + set := make(map[int64]*Mirror) + for i := range mirrors { + set[mirrors[i].RepoID] = mirrors[i] + } + for i := range repos { + repos[i].Mirror = set[repos[i].ID] + } + return nil +} + +func (repos MirrorRepositoryList) LoadAttributes() error { + return repos.loadAttributes(x) +} + +// __ __ __ .__ +// / \ / \_____ _/ |_ ____ | |__ +// \ \/\/ /\__ \\ __\/ ___\| | \ +// \ / / __ \| | \ \___| Y \ +// \__/\ / (____ /__| \___ >___| / +// \/ \/ \/ \/ + +// Watch is connection request for receiving repository notification. +type Watch struct { + ID int64 + UserID int64 `xorm:"UNIQUE(watch)"` + RepoID int64 `xorm:"UNIQUE(watch)"` +} + +func isWatching(e Engine, userID, repoID int64) bool { + has, _ := e.Get(&Watch{0, userID, repoID}) + return has +} + +// IsWatching checks if user has watched given repository. +func IsWatching(userID, repoID int64) bool { + return isWatching(x, userID, repoID) +} + +func watchRepo(e Engine, userID, repoID int64, watch bool) (err error) { + if watch { + if isWatching(e, userID, repoID) { + return nil + } + if _, err = e.Insert(&Watch{RepoID: repoID, UserID: userID}); err != nil { + return err + } + _, err = e.Exec("UPDATE `repository` SET num_watches = num_watches + 1 WHERE id = ?", repoID) + } else { + if !isWatching(e, userID, repoID) { + return nil + } + if _, err = e.Delete(&Watch{0, userID, repoID}); err != nil { + return err + } + _, err = e.Exec("UPDATE `repository` SET num_watches = num_watches - 1 WHERE id = ?", repoID) + } + return err +} + +// Watch or unwatch repository. +func WatchRepo(userID, repoID int64, watch bool) (err error) { + return watchRepo(x, userID, repoID, watch) +} + +func getWatchers(e Engine, repoID int64) ([]*Watch, error) { + watches := make([]*Watch, 0, 10) + return watches, e.Find(&watches, &Watch{RepoID: repoID}) +} + +// GetWatchers returns all watchers of given repository. +func GetWatchers(repoID int64) ([]*Watch, error) { + return getWatchers(x, repoID) +} + +// Repository.GetWatchers returns range of users watching given repository. +func (repo *Repository) GetWatchers(page int) ([]*User, error) { + users := make([]*User, 0, ItemsPerPage) + sess := x.Limit(ItemsPerPage, (page-1)*ItemsPerPage).Where("watch.repo_id=?", repo.ID) + if setting.UsePostgreSQL { + sess = sess.Join("LEFT", "watch", `"user".id=watch.user_id`) + } else { + sess = sess.Join("LEFT", "watch", "user.id=watch.user_id") + } + return users, sess.Find(&users) +} + +func notifyWatchers(e Engine, act *Action) error { + // Add feeds for user self and all watchers. + watchers, err := getWatchers(e, act.RepoID) + if err != nil { + return fmt.Errorf("getWatchers: %v", err) + } + + // Reset ID to reuse Action object + act.ID = 0 + + // Add feed for actioner. + act.UserID = act.ActUserID + if _, err = e.Insert(act); err != nil { + return fmt.Errorf("insert new action: %v", err) + } + + for i := range watchers { + if act.ActUserID == watchers[i].UserID { + continue + } + + act.ID = 0 + act.UserID = watchers[i].UserID + if _, err = e.Insert(act); err != nil { + return fmt.Errorf("insert new action: %v", err) + } + } + return nil +} + +// NotifyWatchers creates batch of actions for every watcher. +func NotifyWatchers(act *Action) error { + return notifyWatchers(x, act) +} + +// _________ __ +// / _____// |______ _______ +// \_____ \\ __\__ \\_ __ \ +// / \| | / __ \| | \/ +// /_______ /|__| (____ /__| +// \/ \/ + +type Star struct { + ID int64 + UID int64 `xorm:"UNIQUE(s)"` + RepoID int64 `xorm:"UNIQUE(s)"` +} + +// Star or unstar repository. +func StarRepo(userID, repoID int64, star bool) (err error) { + if star { + if IsStaring(userID, repoID) { + return nil + } + if _, err = x.Insert(&Star{UID: userID, RepoID: repoID}); err != nil { + return err + } else if _, err = x.Exec("UPDATE `repository` SET num_stars = num_stars + 1 WHERE id = ?", repoID); err != nil { + return err + } + _, err = x.Exec("UPDATE `user` SET num_stars = num_stars + 1 WHERE id = ?", userID) + } else { + if !IsStaring(userID, repoID) { + return nil + } + if _, err = x.Delete(&Star{0, userID, repoID}); err != nil { + return err + } else if _, err = x.Exec("UPDATE `repository` SET num_stars = num_stars - 1 WHERE id = ?", repoID); err != nil { + return err + } + _, err = x.Exec("UPDATE `user` SET num_stars = num_stars - 1 WHERE id = ?", userID) + } + return err +} + +// IsStaring checks if user has starred given repository. +func IsStaring(userID, repoID int64) bool { + has, _ := x.Get(&Star{0, userID, repoID}) + return has +} + +func (repo *Repository) GetStargazers(page int) ([]*User, error) { + users := make([]*User, 0, ItemsPerPage) + sess := x.Limit(ItemsPerPage, (page-1)*ItemsPerPage).Where("star.repo_id=?", repo.ID) + if setting.UsePostgreSQL { + sess = sess.Join("LEFT", "star", `"user".id=star.uid`) + } else { + sess = sess.Join("LEFT", "star", "user.id=star.uid") + } + return users, sess.Find(&users) +} + +// ___________ __ +// \_ _____/__________| | __ +// | __)/ _ \_ __ \ |/ / +// | \( <_> ) | \/ < +// \___ / \____/|__| |__|_ \ +// \/ \/ + +// HasForkedRepo checks if given user has already forked a repository. +// When user has already forked, it returns true along with the repository. +func HasForkedRepo(ownerID, repoID int64) (*Repository, bool, error) { + repo := new(Repository) + has, err := x.Where("owner_id = ? AND fork_id = ?", ownerID, repoID).Get(repo) + if err != nil { + return nil, false, err + } else if !has { + return nil, false, nil + } + return repo, true, repo.LoadAttributes() +} + +// ForkRepository creates a fork of target repository under another user domain. +func ForkRepository(doer, owner *User, baseRepo *Repository, name, desc string) (_ *Repository, err error) { + if !owner.CanCreateRepo() { + return nil, errors.ReachLimitOfRepo{owner.RepoCreationNum()} + } + + repo := &Repository{ + OwnerID: owner.ID, + Owner: owner, + Name: name, + LowerName: strings.ToLower(name), + Description: desc, + DefaultBranch: baseRepo.DefaultBranch, + IsPrivate: baseRepo.IsPrivate, + IsFork: true, + ForkID: baseRepo.ID, + } + + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return nil, err + } + + if err = createRepository(sess, doer, owner, repo); err != nil { + return nil, err + } else if _, err = sess.Exec("UPDATE `repository` SET num_forks=num_forks+1 WHERE id=?", baseRepo.ID); err != nil { + return nil, err + } + + repoPath := repo.repoPath(sess) + RemoveAllWithNotice("Repository path erase before creation", repoPath) + + _, stderr, err := process.ExecTimeout(10*time.Minute, + fmt.Sprintf("ForkRepository 'git clone': %s/%s", owner.Name, repo.Name), + "git", "clone", "--bare", baseRepo.RepoPath(), repoPath) + if err != nil { + return nil, fmt.Errorf("git clone: %v", stderr) + } + + _, stderr, err = process.ExecDir(-1, + repoPath, fmt.Sprintf("ForkRepository 'git update-server-info': %s", repoPath), + "git", "update-server-info") + if err != nil { + return nil, fmt.Errorf("git update-server-info: %v", err) + } + + if err = createDelegateHooks(repoPath); err != nil { + return nil, fmt.Errorf("createDelegateHooks: %v", err) + } + + if err = sess.Commit(); err != nil { + return nil, fmt.Errorf("Commit: %v", err) + } + + if err = repo.UpdateSize(); err != nil { + log.Error(2, "UpdateSize [repo_id: %d]: %v", repo.ID, err) + } + if err = PrepareWebhooks(baseRepo, HOOK_EVENT_FORK, &api.ForkPayload{ + Forkee: repo.APIFormat(nil), + Repo: baseRepo.APIFormat(nil), + Sender: doer.APIFormat(), + }); err != nil { + log.Error(2, "PrepareWebhooks [repo_id: %d]: %v", baseRepo.ID, err) + } + return repo, nil +} + +func (repo *Repository) GetForks() ([]*Repository, error) { + forks := make([]*Repository, 0, repo.NumForks) + if err := x.Find(&forks, &Repository{ForkID: repo.ID}); err != nil { + return nil, err + } + + for _, fork := range forks { + fork.BaseRepo = repo + } + return forks, nil +} + +// __________ .__ +// \______ \____________ ____ ____ | |__ +// | | _/\_ __ \__ \ / \_/ ___\| | \ +// | | \ | | \// __ \| | \ \___| Y \ +// |______ / |__| (____ /___| /\___ >___| / +// \/ \/ \/ \/ \/ +// + +func (repo *Repository) CreateNewBranch(doer *User, oldBranchName, branchName string) (err error) { + repoWorkingPool.CheckIn(com.ToStr(repo.ID)) + defer repoWorkingPool.CheckOut(com.ToStr(repo.ID)) + + localPath := repo.LocalCopyPath() + + if err = discardLocalRepoBranchChanges(localPath, oldBranchName); err != nil { + return fmt.Errorf("discardLocalRepoChanges: %v", err) + } else if err = repo.UpdateLocalCopyBranch(oldBranchName); err != nil { + return fmt.Errorf("UpdateLocalCopyBranch: %v", err) + } + + if err = repo.CheckoutNewBranch(oldBranchName, branchName); err != nil { + return fmt.Errorf("CreateNewBranch: %v", err) + } + + if err = git.Push(localPath, "origin", branchName); err != nil { + return fmt.Errorf("Push: %v", err) + } + + return nil +} diff --git a/internal/db/repo_branch.go b/internal/db/repo_branch.go new file mode 100644 index 00000000..3eb15fef --- /dev/null +++ b/internal/db/repo_branch.go @@ -0,0 +1,257 @@ +// Copyright 2016 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package db + +import ( + "fmt" + "strings" + + "github.com/gogs/git-module" + "github.com/unknwon/com" + + "gogs.io/gogs/internal/db/errors" + "gogs.io/gogs/internal/tool" +) + +type Branch struct { + RepoPath string + Name string + + IsProtected bool + Commit *git.Commit +} + +func GetBranchesByPath(path string) ([]*Branch, error) { + gitRepo, err := git.OpenRepository(path) + if err != nil { + return nil, err + } + + brs, err := gitRepo.GetBranches() + if err != nil { + return nil, err + } + + branches := make([]*Branch, len(brs)) + for i := range brs { + branches[i] = &Branch{ + RepoPath: path, + Name: brs[i], + } + } + return branches, nil +} + +func (repo *Repository) GetBranch(br string) (*Branch, error) { + if !git.IsBranchExist(repo.RepoPath(), br) { + return nil, errors.ErrBranchNotExist{br} + } + return &Branch{ + RepoPath: repo.RepoPath(), + Name: br, + }, nil +} + +func (repo *Repository) GetBranches() ([]*Branch, error) { + return GetBranchesByPath(repo.RepoPath()) +} + +func (br *Branch) GetCommit() (*git.Commit, error) { + gitRepo, err := git.OpenRepository(br.RepoPath) + if err != nil { + return nil, err + } + return gitRepo.GetBranchCommit(br.Name) +} + +type ProtectBranchWhitelist struct { + ID int64 + ProtectBranchID int64 + RepoID int64 `xorm:"UNIQUE(protect_branch_whitelist)"` + Name string `xorm:"UNIQUE(protect_branch_whitelist)"` + UserID int64 `xorm:"UNIQUE(protect_branch_whitelist)"` +} + +// IsUserInProtectBranchWhitelist returns true if given user is in the whitelist of a branch in a repository. +func IsUserInProtectBranchWhitelist(repoID, userID int64, branch string) bool { + has, err := x.Where("repo_id = ?", repoID).And("user_id = ?", userID).And("name = ?", branch).Get(new(ProtectBranchWhitelist)) + return has && err == nil +} + +// ProtectBranch contains options of a protected branch. +type ProtectBranch struct { + ID int64 + RepoID int64 `xorm:"UNIQUE(protect_branch)"` + Name string `xorm:"UNIQUE(protect_branch)"` + Protected bool + RequirePullRequest bool + EnableWhitelist bool + WhitelistUserIDs string `xorm:"TEXT"` + WhitelistTeamIDs string `xorm:"TEXT"` +} + +// GetProtectBranchOfRepoByName returns *ProtectBranch by branch name in given repostiory. +func GetProtectBranchOfRepoByName(repoID int64, name string) (*ProtectBranch, error) { + protectBranch := &ProtectBranch{ + RepoID: repoID, + Name: name, + } + has, err := x.Get(protectBranch) + if err != nil { + return nil, err + } else if !has { + return nil, errors.ErrBranchNotExist{name} + } + return protectBranch, nil +} + +// IsBranchOfRepoRequirePullRequest returns true if branch requires pull request in given repository. +func IsBranchOfRepoRequirePullRequest(repoID int64, name string) bool { + protectBranch, err := GetProtectBranchOfRepoByName(repoID, name) + if err != nil { + return false + } + return protectBranch.Protected && protectBranch.RequirePullRequest +} + +// UpdateProtectBranch saves branch protection options. +// If ID is 0, it creates a new record. Otherwise, updates existing record. +func UpdateProtectBranch(protectBranch *ProtectBranch) (err error) { + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return err + } + + if protectBranch.ID == 0 { + if _, err = sess.Insert(protectBranch); err != nil { + return fmt.Errorf("Insert: %v", err) + } + } + + if _, err = sess.ID(protectBranch.ID).AllCols().Update(protectBranch); err != nil { + return fmt.Errorf("Update: %v", err) + } + + return sess.Commit() +} + +// UpdateOrgProtectBranch saves branch protection options of organizational repository. +// If ID is 0, it creates a new record. Otherwise, updates existing record. +// This function also performs check if whitelist user and team's IDs have been changed +// to avoid unnecessary whitelist delete and regenerate. +func UpdateOrgProtectBranch(repo *Repository, protectBranch *ProtectBranch, whitelistUserIDs, whitelistTeamIDs string) (err error) { + if err = repo.GetOwner(); err != nil { + return fmt.Errorf("GetOwner: %v", err) + } else if !repo.Owner.IsOrganization() { + return fmt.Errorf("expect repository owner to be an organization") + } + + hasUsersChanged := false + validUserIDs := tool.StringsToInt64s(strings.Split(protectBranch.WhitelistUserIDs, ",")) + if protectBranch.WhitelistUserIDs != whitelistUserIDs { + hasUsersChanged = true + userIDs := tool.StringsToInt64s(strings.Split(whitelistUserIDs, ",")) + validUserIDs = make([]int64, 0, len(userIDs)) + for _, userID := range userIDs { + has, err := HasAccess(userID, repo, ACCESS_MODE_WRITE) + if err != nil { + return fmt.Errorf("HasAccess [user_id: %d, repo_id: %d]: %v", userID, protectBranch.RepoID, err) + } else if !has { + continue // Drop invalid user ID + } + + validUserIDs = append(validUserIDs, userID) + } + + protectBranch.WhitelistUserIDs = strings.Join(tool.Int64sToStrings(validUserIDs), ",") + } + + hasTeamsChanged := false + validTeamIDs := tool.StringsToInt64s(strings.Split(protectBranch.WhitelistTeamIDs, ",")) + if protectBranch.WhitelistTeamIDs != whitelistTeamIDs { + hasTeamsChanged = true + teamIDs := tool.StringsToInt64s(strings.Split(whitelistTeamIDs, ",")) + teams, err := GetTeamsHaveAccessToRepo(repo.OwnerID, repo.ID, ACCESS_MODE_WRITE) + if err != nil { + return fmt.Errorf("GetTeamsHaveAccessToRepo [org_id: %d, repo_id: %d]: %v", repo.OwnerID, repo.ID, err) + } + validTeamIDs = make([]int64, 0, len(teams)) + for i := range teams { + if teams[i].HasWriteAccess() && com.IsSliceContainsInt64(teamIDs, teams[i].ID) { + validTeamIDs = append(validTeamIDs, teams[i].ID) + } + } + + protectBranch.WhitelistTeamIDs = strings.Join(tool.Int64sToStrings(validTeamIDs), ",") + } + + // Make sure protectBranch.ID is not 0 for whitelists + if protectBranch.ID == 0 { + if _, err = x.Insert(protectBranch); err != nil { + return fmt.Errorf("Insert: %v", err) + } + } + + // Merge users and members of teams + var whitelists []*ProtectBranchWhitelist + if hasUsersChanged || hasTeamsChanged { + mergedUserIDs := make(map[int64]bool) + for _, userID := range validUserIDs { + // Empty whitelist users can cause an ID with 0 + if userID != 0 { + mergedUserIDs[userID] = true + } + } + + for _, teamID := range validTeamIDs { + members, err := GetTeamMembers(teamID) + if err != nil { + return fmt.Errorf("GetTeamMembers [team_id: %d]: %v", teamID, err) + } + + for i := range members { + mergedUserIDs[members[i].ID] = true + } + } + + whitelists = make([]*ProtectBranchWhitelist, 0, len(mergedUserIDs)) + for userID := range mergedUserIDs { + whitelists = append(whitelists, &ProtectBranchWhitelist{ + ProtectBranchID: protectBranch.ID, + RepoID: repo.ID, + Name: protectBranch.Name, + UserID: userID, + }) + } + } + + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return err + } + + if _, err = sess.ID(protectBranch.ID).AllCols().Update(protectBranch); err != nil { + return fmt.Errorf("Update: %v", err) + } + + // Refresh whitelists + if hasUsersChanged || hasTeamsChanged { + if _, err = sess.Delete(&ProtectBranchWhitelist{ProtectBranchID: protectBranch.ID}); err != nil { + return fmt.Errorf("delete old protect branch whitelists: %v", err) + } else if _, err = sess.Insert(whitelists); err != nil { + return fmt.Errorf("insert new protect branch whitelists: %v", err) + } + } + + return sess.Commit() +} + +// GetProtectBranchesByRepoID returns a list of *ProtectBranch in given repostiory. +func GetProtectBranchesByRepoID(repoID int64) ([]*ProtectBranch, error) { + protectBranches := make([]*ProtectBranch, 0, 2) + return protectBranches, x.Where("repo_id = ? and protected = ?", repoID, true).Asc("name").Find(&protectBranches) +} diff --git a/internal/db/repo_collaboration.go b/internal/db/repo_collaboration.go new file mode 100644 index 00000000..84059c0a --- /dev/null +++ b/internal/db/repo_collaboration.go @@ -0,0 +1,226 @@ +// Copyright 2016 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package db + +import ( + "fmt" + + log "gopkg.in/clog.v1" + + api "github.com/gogs/go-gogs-client" +) + +// Collaboration represent the relation between an individual and a repository. +type Collaboration struct { + ID int64 + RepoID int64 `xorm:"UNIQUE(s) INDEX NOT NULL"` + UserID int64 `xorm:"UNIQUE(s) INDEX NOT NULL"` + Mode AccessMode `xorm:"DEFAULT 2 NOT NULL"` +} + +func (c *Collaboration) ModeI18nKey() string { + switch c.Mode { + case ACCESS_MODE_READ: + return "repo.settings.collaboration.read" + case ACCESS_MODE_WRITE: + return "repo.settings.collaboration.write" + case ACCESS_MODE_ADMIN: + return "repo.settings.collaboration.admin" + default: + return "repo.settings.collaboration.undefined" + } +} + +// IsCollaborator returns true if the user is a collaborator of the repository. +func IsCollaborator(repoID, userID int64) bool { + collaboration := &Collaboration{ + RepoID: repoID, + UserID: userID, + } + has, err := x.Get(collaboration) + if err != nil { + log.Error(2, "get collaboration [repo_id: %d, user_id: %d]: %v", repoID, userID, err) + return false + } + return has +} + +func (repo *Repository) IsCollaborator(userID int64) bool { + return IsCollaborator(repo.ID, userID) +} + +// AddCollaborator adds new collaboration to a repository with default access mode. +func (repo *Repository) AddCollaborator(u *User) error { + collaboration := &Collaboration{ + RepoID: repo.ID, + UserID: u.ID, + } + + has, err := x.Get(collaboration) + if err != nil { + return err + } else if has { + return nil + } + collaboration.Mode = ACCESS_MODE_WRITE + + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return err + } + + if _, err = sess.Insert(collaboration); err != nil { + return err + } else if err = repo.recalculateAccesses(sess); err != nil { + return fmt.Errorf("recalculateAccesses [repo_id: %v]: %v", repo.ID, err) + } + + return sess.Commit() +} + +func (repo *Repository) getCollaborations(e Engine) ([]*Collaboration, error) { + collaborations := make([]*Collaboration, 0) + return collaborations, e.Find(&collaborations, &Collaboration{RepoID: repo.ID}) +} + +// Collaborator represents a user with collaboration details. +type Collaborator struct { + *User + Collaboration *Collaboration +} + +func (c *Collaborator) APIFormat() *api.Collaborator { + return &api.Collaborator{ + User: c.User.APIFormat(), + Permissions: api.Permission{ + Admin: c.Collaboration.Mode >= ACCESS_MODE_ADMIN, + Push: c.Collaboration.Mode >= ACCESS_MODE_WRITE, + Pull: c.Collaboration.Mode >= ACCESS_MODE_READ, + }, + } +} + +func (repo *Repository) getCollaborators(e Engine) ([]*Collaborator, error) { + collaborations, err := repo.getCollaborations(e) + if err != nil { + return nil, fmt.Errorf("getCollaborations: %v", err) + } + + collaborators := make([]*Collaborator, len(collaborations)) + for i, c := range collaborations { + user, err := getUserByID(e, c.UserID) + if err != nil { + return nil, err + } + collaborators[i] = &Collaborator{ + User: user, + Collaboration: c, + } + } + return collaborators, nil +} + +// GetCollaborators returns the collaborators for a repository +func (repo *Repository) GetCollaborators() ([]*Collaborator, error) { + return repo.getCollaborators(x) +} + +// ChangeCollaborationAccessMode sets new access mode for the collaboration. +func (repo *Repository) ChangeCollaborationAccessMode(userID int64, mode AccessMode) error { + // Discard invalid input + if mode <= ACCESS_MODE_NONE || mode > ACCESS_MODE_OWNER { + return nil + } + + collaboration := &Collaboration{ + RepoID: repo.ID, + UserID: userID, + } + has, err := x.Get(collaboration) + if err != nil { + return fmt.Errorf("get collaboration: %v", err) + } else if !has { + return nil + } + + if collaboration.Mode == mode { + return nil + } + collaboration.Mode = mode + + // If it's an organizational repository, merge with team access level for highest permission + if repo.Owner.IsOrganization() { + teams, err := GetUserTeams(repo.OwnerID, userID) + if err != nil { + return fmt.Errorf("GetUserTeams: [org_id: %d, user_id: %d]: %v", repo.OwnerID, userID, err) + } + for i := range teams { + if mode < teams[i].Authorize { + mode = teams[i].Authorize + } + } + } + + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return err + } + + if _, err = sess.ID(collaboration.ID).AllCols().Update(collaboration); err != nil { + return fmt.Errorf("update collaboration: %v", err) + } + + access := &Access{ + UserID: userID, + RepoID: repo.ID, + } + has, err = sess.Get(access) + if err != nil { + return fmt.Errorf("get access record: %v", err) + } + if has { + _, err = sess.Exec("UPDATE access SET mode = ? WHERE user_id = ? AND repo_id = ?", mode, userID, repo.ID) + } else { + access.Mode = mode + _, err = sess.Insert(access) + } + if err != nil { + return fmt.Errorf("update/insert access table: %v", err) + } + + return sess.Commit() +} + +// DeleteCollaboration removes collaboration relation between the user and repository. +func DeleteCollaboration(repo *Repository, userID int64) (err error) { + if !IsCollaborator(repo.ID, userID) { + return nil + } + + collaboration := &Collaboration{ + RepoID: repo.ID, + UserID: userID, + } + + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return err + } + + if has, err := sess.Delete(collaboration); err != nil || has == 0 { + return err + } else if err = repo.recalculateAccesses(sess); err != nil { + return err + } + + return sess.Commit() +} + +func (repo *Repository) DeleteCollaboration(userID int64) error { + return DeleteCollaboration(repo, userID) +} diff --git a/internal/db/repo_editor.go b/internal/db/repo_editor.go new file mode 100644 index 00000000..f224ac49 --- /dev/null +++ b/internal/db/repo_editor.go @@ -0,0 +1,518 @@ +// Copyright 2016 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package db + +import ( + "fmt" + "io" + "io/ioutil" + "mime/multipart" + "os" + "os/exec" + "path" + "path/filepath" + "strings" + "time" + + gouuid "github.com/satori/go.uuid" + "github.com/unknwon/com" + + "github.com/gogs/git-module" + + "gogs.io/gogs/internal/db/errors" + "gogs.io/gogs/internal/process" + "gogs.io/gogs/internal/setting" + "gogs.io/gogs/internal/tool" +) + +const ( + ENV_AUTH_USER_ID = "GOGS_AUTH_USER_ID" + ENV_AUTH_USER_NAME = "GOGS_AUTH_USER_NAME" + ENV_AUTH_USER_EMAIL = "GOGS_AUTH_USER_EMAIL" + ENV_REPO_OWNER_NAME = "GOGS_REPO_OWNER_NAME" + ENV_REPO_OWNER_SALT_MD5 = "GOGS_REPO_OWNER_SALT_MD5" + ENV_REPO_ID = "GOGS_REPO_ID" + ENV_REPO_NAME = "GOGS_REPO_NAME" + ENV_REPO_CUSTOM_HOOKS_PATH = "GOGS_REPO_CUSTOM_HOOKS_PATH" +) + +type ComposeHookEnvsOptions struct { + AuthUser *User + OwnerName string + OwnerSalt string + RepoID int64 + RepoName string + RepoPath string +} + +func ComposeHookEnvs(opts ComposeHookEnvsOptions) []string { + envs := []string{ + "SSH_ORIGINAL_COMMAND=1", + ENV_AUTH_USER_ID + "=" + com.ToStr(opts.AuthUser.ID), + ENV_AUTH_USER_NAME + "=" + opts.AuthUser.Name, + ENV_AUTH_USER_EMAIL + "=" + opts.AuthUser.Email, + ENV_REPO_OWNER_NAME + "=" + opts.OwnerName, + ENV_REPO_OWNER_SALT_MD5 + "=" + tool.MD5(opts.OwnerSalt), + ENV_REPO_ID + "=" + com.ToStr(opts.RepoID), + ENV_REPO_NAME + "=" + opts.RepoName, + ENV_REPO_CUSTOM_HOOKS_PATH + "=" + path.Join(opts.RepoPath, "custom_hooks"), + } + return envs +} + +// ___________ .___.__ __ ___________.__.__ +// \_ _____/ __| _/|__|/ |_ \_ _____/|__| | ____ +// | __)_ / __ | | \ __\ | __) | | | _/ __ \ +// | \/ /_/ | | || | | \ | | |_\ ___/ +// /_______ /\____ | |__||__| \___ / |__|____/\___ > +// \/ \/ \/ \/ + +// discardLocalRepoBranchChanges discards local commits/changes of +// given branch to make sure it is even to remote branch. +func discardLocalRepoBranchChanges(localPath, branch string) error { + if !com.IsExist(localPath) { + return nil + } + // No need to check if nothing in the repository. + if !git.IsBranchExist(localPath, branch) { + return nil + } + + refName := "origin/" + branch + if err := git.ResetHEAD(localPath, true, refName); err != nil { + return fmt.Errorf("git reset --hard %s: %v", refName, err) + } + return nil +} + +func (repo *Repository) DiscardLocalRepoBranchChanges(branch string) error { + return discardLocalRepoBranchChanges(repo.LocalCopyPath(), branch) +} + +// checkoutNewBranch checks out to a new branch from the a branch name. +func checkoutNewBranch(repoPath, localPath, oldBranch, newBranch string) error { + if err := git.Checkout(localPath, git.CheckoutOptions{ + Timeout: time.Duration(setting.Git.Timeout.Pull) * time.Second, + Branch: newBranch, + OldBranch: oldBranch, + }); err != nil { + return fmt.Errorf("git checkout -b %s %s: %v", newBranch, oldBranch, err) + } + return nil +} + +func (repo *Repository) CheckoutNewBranch(oldBranch, newBranch string) error { + return checkoutNewBranch(repo.RepoPath(), repo.LocalCopyPath(), oldBranch, newBranch) +} + +type UpdateRepoFileOptions struct { + LastCommitID string + OldBranch string + NewBranch string + OldTreeName string + NewTreeName string + Message string + Content string + IsNewFile bool +} + +// UpdateRepoFile adds or updates a file in repository. +func (repo *Repository) UpdateRepoFile(doer *User, opts UpdateRepoFileOptions) (err error) { + repoWorkingPool.CheckIn(com.ToStr(repo.ID)) + defer repoWorkingPool.CheckOut(com.ToStr(repo.ID)) + + if err = repo.DiscardLocalRepoBranchChanges(opts.OldBranch); err != nil { + return fmt.Errorf("discard local repo branch[%s] changes: %v", opts.OldBranch, err) + } else if err = repo.UpdateLocalCopyBranch(opts.OldBranch); err != nil { + return fmt.Errorf("update local copy branch[%s]: %v", opts.OldBranch, err) + } + + repoPath := repo.RepoPath() + localPath := repo.LocalCopyPath() + + if opts.OldBranch != opts.NewBranch { + // Directly return error if new branch already exists in the server + if git.IsBranchExist(repoPath, opts.NewBranch) { + return errors.BranchAlreadyExists{opts.NewBranch} + } + + // Otherwise, delete branch from local copy in case out of sync + if git.IsBranchExist(localPath, opts.NewBranch) { + if err = git.DeleteBranch(localPath, opts.NewBranch, git.DeleteBranchOptions{ + Force: true, + }); err != nil { + return fmt.Errorf("delete branch[%s]: %v", opts.NewBranch, err) + } + } + + if err := repo.CheckoutNewBranch(opts.OldBranch, opts.NewBranch); err != nil { + return fmt.Errorf("checkout new branch[%s] from old branch[%s]: %v", opts.NewBranch, opts.OldBranch, err) + } + } + + oldFilePath := path.Join(localPath, opts.OldTreeName) + filePath := path.Join(localPath, opts.NewTreeName) + os.MkdirAll(path.Dir(filePath), os.ModePerm) + + // If it's meant to be a new file, make sure it doesn't exist. + if opts.IsNewFile { + if com.IsExist(filePath) { + return ErrRepoFileAlreadyExist{filePath} + } + } + + // Ignore move step if it's a new file under a directory. + // Otherwise, move the file when name changed. + if com.IsFile(oldFilePath) && opts.OldTreeName != opts.NewTreeName { + if err = git.MoveFile(localPath, opts.OldTreeName, opts.NewTreeName); err != nil { + return fmt.Errorf("git mv %q %q: %v", opts.OldTreeName, opts.NewTreeName, err) + } + } + + if err = ioutil.WriteFile(filePath, []byte(opts.Content), 0666); err != nil { + return fmt.Errorf("write file: %v", err) + } + + if err = git.AddChanges(localPath, true); err != nil { + return fmt.Errorf("git add --all: %v", err) + } else if err = git.CommitChanges(localPath, git.CommitChangesOptions{ + Committer: doer.NewGitSig(), + Message: opts.Message, + }); err != nil { + return fmt.Errorf("commit changes on %q: %v", localPath, err) + } else if err = git.PushWithEnvs(localPath, "origin", opts.NewBranch, + ComposeHookEnvs(ComposeHookEnvsOptions{ + AuthUser: doer, + OwnerName: repo.MustOwner().Name, + OwnerSalt: repo.MustOwner().Salt, + RepoID: repo.ID, + RepoName: repo.Name, + RepoPath: repo.RepoPath(), + })); err != nil { + return fmt.Errorf("git push origin %s: %v", opts.NewBranch, err) + } + return nil +} + +// GetDiffPreview produces and returns diff result of a file which is not yet committed. +func (repo *Repository) GetDiffPreview(branch, treePath, content string) (diff *Diff, err error) { + repoWorkingPool.CheckIn(com.ToStr(repo.ID)) + defer repoWorkingPool.CheckOut(com.ToStr(repo.ID)) + + if err = repo.DiscardLocalRepoBranchChanges(branch); err != nil { + return nil, fmt.Errorf("discard local repo branch[%s] changes: %v", branch, err) + } else if err = repo.UpdateLocalCopyBranch(branch); err != nil { + return nil, fmt.Errorf("update local copy branch[%s]: %v", branch, err) + } + + localPath := repo.LocalCopyPath() + filePath := path.Join(localPath, treePath) + os.MkdirAll(filepath.Dir(filePath), os.ModePerm) + if err = ioutil.WriteFile(filePath, []byte(content), 0666); err != nil { + return nil, fmt.Errorf("write file: %v", err) + } + + cmd := exec.Command("git", "diff", treePath) + cmd.Dir = localPath + cmd.Stderr = os.Stderr + + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, fmt.Errorf("get stdout pipe: %v", err) + } + + if err = cmd.Start(); err != nil { + return nil, fmt.Errorf("start: %v", err) + } + + pid := process.Add(fmt.Sprintf("GetDiffPreview [repo_path: %s]", repo.RepoPath()), cmd) + defer process.Remove(pid) + + diff, err = ParsePatch(setting.Git.MaxGitDiffLines, setting.Git.MaxGitDiffLineCharacters, setting.Git.MaxGitDiffFiles, stdout) + if err != nil { + return nil, fmt.Errorf("parse path: %v", err) + } + + if err = cmd.Wait(); err != nil { + return nil, fmt.Errorf("wait: %v", err) + } + + return diff, nil +} + +// ________ .__ __ ___________.__.__ +// \______ \ ____ | | _____/ |_ ____ \_ _____/|__| | ____ +// | | \_/ __ \| | _/ __ \ __\/ __ \ | __) | | | _/ __ \ +// | ` \ ___/| |_\ ___/| | \ ___/ | \ | | |_\ ___/ +// /_______ /\___ >____/\___ >__| \___ > \___ / |__|____/\___ > +// \/ \/ \/ \/ \/ \/ +// + +type DeleteRepoFileOptions struct { + LastCommitID string + OldBranch string + NewBranch string + TreePath string + Message string +} + +func (repo *Repository) DeleteRepoFile(doer *User, opts DeleteRepoFileOptions) (err error) { + repoWorkingPool.CheckIn(com.ToStr(repo.ID)) + defer repoWorkingPool.CheckOut(com.ToStr(repo.ID)) + + if err = repo.DiscardLocalRepoBranchChanges(opts.OldBranch); err != nil { + return fmt.Errorf("discard local repo branch[%s] changes: %v", opts.OldBranch, err) + } else if err = repo.UpdateLocalCopyBranch(opts.OldBranch); err != nil { + return fmt.Errorf("update local copy branch[%s]: %v", opts.OldBranch, err) + } + + if opts.OldBranch != opts.NewBranch { + if err := repo.CheckoutNewBranch(opts.OldBranch, opts.NewBranch); err != nil { + return fmt.Errorf("checkout new branch[%s] from old branch[%s]: %v", opts.NewBranch, opts.OldBranch, err) + } + } + + localPath := repo.LocalCopyPath() + if err = os.Remove(path.Join(localPath, opts.TreePath)); err != nil { + return fmt.Errorf("remove file %q: %v", opts.TreePath, err) + } + + if err = git.AddChanges(localPath, true); err != nil { + return fmt.Errorf("git add --all: %v", err) + } else if err = git.CommitChanges(localPath, git.CommitChangesOptions{ + Committer: doer.NewGitSig(), + Message: opts.Message, + }); err != nil { + return fmt.Errorf("commit changes to %q: %v", localPath, err) + } else if err = git.PushWithEnvs(localPath, "origin", opts.NewBranch, + ComposeHookEnvs(ComposeHookEnvsOptions{ + AuthUser: doer, + OwnerName: repo.MustOwner().Name, + OwnerSalt: repo.MustOwner().Salt, + RepoID: repo.ID, + RepoName: repo.Name, + RepoPath: repo.RepoPath(), + })); err != nil { + return fmt.Errorf("git push origin %s: %v", opts.NewBranch, err) + } + return nil +} + +// ____ ___ .__ .___ ___________.___.__ +// | | \______ | | _________ __| _/ \_ _____/| | | ____ ______ +// | | /\____ \| | / _ \__ \ / __ | | __) | | | _/ __ \ / ___/ +// | | / | |_> > |_( <_> ) __ \_/ /_/ | | \ | | |_\ ___/ \___ \ +// |______/ | __/|____/\____(____ /\____ | \___ / |___|____/\___ >____ > +// |__| \/ \/ \/ \/ \/ +// + +// Upload represent a uploaded file to a repo to be deleted when moved +type Upload struct { + ID int64 + UUID string `xorm:"uuid UNIQUE"` + Name string +} + +// UploadLocalPath returns where uploads is stored in local file system based on given UUID. +func UploadLocalPath(uuid string) string { + return path.Join(setting.Repository.Upload.TempPath, uuid[0:1], uuid[1:2], uuid) +} + +// LocalPath returns where uploads are temporarily stored in local file system. +func (upload *Upload) LocalPath() string { + return UploadLocalPath(upload.UUID) +} + +// NewUpload creates a new upload object. +func NewUpload(name string, buf []byte, file multipart.File) (_ *Upload, err error) { + if tool.IsMaliciousPath(name) { + return nil, fmt.Errorf("malicious path detected: %s", name) + } + + upload := &Upload{ + UUID: gouuid.NewV4().String(), + Name: name, + } + + localPath := upload.LocalPath() + if err = os.MkdirAll(path.Dir(localPath), os.ModePerm); err != nil { + return nil, fmt.Errorf("mkdir all: %v", err) + } + + fw, err := os.Create(localPath) + if err != nil { + return nil, fmt.Errorf("create: %v", err) + } + defer fw.Close() + + if _, err = fw.Write(buf); err != nil { + return nil, fmt.Errorf("write: %v", err) + } else if _, err = io.Copy(fw, file); err != nil { + return nil, fmt.Errorf("copy: %v", err) + } + + if _, err := x.Insert(upload); err != nil { + return nil, err + } + + return upload, nil +} + +func GetUploadByUUID(uuid string) (*Upload, error) { + upload := &Upload{UUID: uuid} + has, err := x.Get(upload) + if err != nil { + return nil, err + } else if !has { + return nil, ErrUploadNotExist{0, uuid} + } + return upload, nil +} + +func GetUploadsByUUIDs(uuids []string) ([]*Upload, error) { + if len(uuids) == 0 { + return []*Upload{}, nil + } + + // Silently drop invalid uuids. + uploads := make([]*Upload, 0, len(uuids)) + return uploads, x.In("uuid", uuids).Find(&uploads) +} + +func DeleteUploads(uploads ...*Upload) (err error) { + if len(uploads) == 0 { + return nil + } + + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return err + } + + ids := make([]int64, len(uploads)) + for i := 0; i < len(uploads); i++ { + ids[i] = uploads[i].ID + } + if _, err = sess.In("id", ids).Delete(new(Upload)); err != nil { + return fmt.Errorf("delete uploads: %v", err) + } + + for _, upload := range uploads { + localPath := upload.LocalPath() + if !com.IsFile(localPath) { + continue + } + + if err := os.Remove(localPath); err != nil { + return fmt.Errorf("remove upload: %v", err) + } + } + + return sess.Commit() +} + +func DeleteUpload(u *Upload) error { + return DeleteUploads(u) +} + +func DeleteUploadByUUID(uuid string) error { + upload, err := GetUploadByUUID(uuid) + if err != nil { + if IsErrUploadNotExist(err) { + return nil + } + return fmt.Errorf("get upload by UUID[%s]: %v", uuid, err) + } + + if err := DeleteUpload(upload); err != nil { + return fmt.Errorf("delete upload: %v", err) + } + + return nil +} + +type UploadRepoFileOptions struct { + LastCommitID string + OldBranch string + NewBranch string + TreePath string + Message string + Files []string // In UUID format +} + +// isRepositoryGitPath returns true if given path is or resides inside ".git" path of the repository. +func isRepositoryGitPath(path string) bool { + return strings.HasSuffix(path, ".git") || strings.Contains(path, ".git"+string(os.PathSeparator)) +} + +func (repo *Repository) UploadRepoFiles(doer *User, opts UploadRepoFileOptions) (err error) { + if len(opts.Files) == 0 { + return nil + } + + uploads, err := GetUploadsByUUIDs(opts.Files) + if err != nil { + return fmt.Errorf("get uploads by UUIDs[%v]: %v", opts.Files, err) + } + + repoWorkingPool.CheckIn(com.ToStr(repo.ID)) + defer repoWorkingPool.CheckOut(com.ToStr(repo.ID)) + + if err = repo.DiscardLocalRepoBranchChanges(opts.OldBranch); err != nil { + return fmt.Errorf("discard local repo branch[%s] changes: %v", opts.OldBranch, err) + } else if err = repo.UpdateLocalCopyBranch(opts.OldBranch); err != nil { + return fmt.Errorf("update local copy branch[%s]: %v", opts.OldBranch, err) + } + + if opts.OldBranch != opts.NewBranch { + if err = repo.CheckoutNewBranch(opts.OldBranch, opts.NewBranch); err != nil { + return fmt.Errorf("checkout new branch[%s] from old branch[%s]: %v", opts.NewBranch, opts.OldBranch, err) + } + } + + localPath := repo.LocalCopyPath() + dirPath := path.Join(localPath, opts.TreePath) + os.MkdirAll(dirPath, os.ModePerm) + + // Copy uploaded files into repository + for _, upload := range uploads { + tmpPath := upload.LocalPath() + if !com.IsFile(tmpPath) { + continue + } + + // Prevent copying files into .git directory, see https://gogs.io/gogs/issues/5558. + if isRepositoryGitPath(upload.Name) { + continue + } + + targetPath := path.Join(dirPath, upload.Name) + if err = com.Copy(tmpPath, targetPath); err != nil { + return fmt.Errorf("copy: %v", err) + } + } + + if err = git.AddChanges(localPath, true); err != nil { + return fmt.Errorf("git add --all: %v", err) + } else if err = git.CommitChanges(localPath, git.CommitChangesOptions{ + Committer: doer.NewGitSig(), + Message: opts.Message, + }); err != nil { + return fmt.Errorf("commit changes on %q: %v", localPath, err) + } else if err = git.PushWithEnvs(localPath, "origin", opts.NewBranch, + ComposeHookEnvs(ComposeHookEnvsOptions{ + AuthUser: doer, + OwnerName: repo.MustOwner().Name, + OwnerSalt: repo.MustOwner().Salt, + RepoID: repo.ID, + RepoName: repo.Name, + RepoPath: repo.RepoPath(), + })); err != nil { + return fmt.Errorf("git push origin %s: %v", opts.NewBranch, err) + } + + return DeleteUploads(uploads...) +} diff --git a/internal/db/repo_editor_test.go b/internal/db/repo_editor_test.go new file mode 100644 index 00000000..18e844d0 --- /dev/null +++ b/internal/db/repo_editor_test.go @@ -0,0 +1,34 @@ +// Copyright 2018 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package db + +import ( + "os" + "testing" + + . "github.com/smartystreets/goconvey/convey" +) + +func Test_isRepositoryGitPath(t *testing.T) { + Convey("Check if path is or resides inside '.git'", t, func() { + sep := string(os.PathSeparator) + testCases := []struct { + path string + expect bool + }{ + {"." + sep + ".git", true}, + {"." + sep + ".git" + sep + "", true}, + {"." + sep + ".git" + sep + "hooks" + sep + "pre-commit", true}, + {".git" + sep + "hooks", true}, + {"dir" + sep + ".git", true}, + + {".gitignore", false}, + {"dir" + sep + ".gitkeep", false}, + } + for _, tc := range testCases { + So(isRepositoryGitPath(tc.path), ShouldEqual, tc.expect) + } + }) +} diff --git a/internal/db/repo_test.go b/internal/db/repo_test.go new file mode 100644 index 00000000..c239e74d --- /dev/null +++ b/internal/db/repo_test.go @@ -0,0 +1,63 @@ +package db_test + +import ( + "testing" + + . "github.com/smartystreets/goconvey/convey" + + "gogs.io/gogs/internal/db" + "gogs.io/gogs/internal/markup" +) + +func TestRepo(t *testing.T) { + Convey("The metas map", t, func() { + var repo = new(db.Repository) + repo.Name = "testrepo" + repo.Owner = new(db.User) + repo.Owner.Name = "testuser" + repo.ExternalTrackerFormat = "https://someurl.com/{user}/{repo}/{issue}" + + Convey("When no external tracker is configured", func() { + Convey("It should be nil", func() { + repo.EnableExternalTracker = false + So(repo.ComposeMetas(), ShouldEqual, map[string]string(nil)) + }) + Convey("It should be nil even if other settings are present", func() { + repo.EnableExternalTracker = false + repo.ExternalTrackerFormat = "http://someurl.com/{user}/{repo}/{issue}" + repo.ExternalTrackerStyle = markup.ISSUE_NAME_STYLE_NUMERIC + So(repo.ComposeMetas(), ShouldEqual, map[string]string(nil)) + }) + }) + + Convey("When an external issue tracker is configured", func() { + repo.EnableExternalTracker = true + Convey("It should default to numeric issue style", func() { + metas := repo.ComposeMetas() + So(metas["style"], ShouldEqual, markup.ISSUE_NAME_STYLE_NUMERIC) + }) + Convey("It should pass through numeric issue style setting", func() { + repo.ExternalTrackerStyle = markup.ISSUE_NAME_STYLE_NUMERIC + metas := repo.ComposeMetas() + So(metas["style"], ShouldEqual, markup.ISSUE_NAME_STYLE_NUMERIC) + }) + Convey("It should pass through alphanumeric issue style setting", func() { + repo.ExternalTrackerStyle = markup.ISSUE_NAME_STYLE_ALPHANUMERIC + metas := repo.ComposeMetas() + So(metas["style"], ShouldEqual, markup.ISSUE_NAME_STYLE_ALPHANUMERIC) + }) + Convey("It should contain the user name", func() { + metas := repo.ComposeMetas() + So(metas["user"], ShouldEqual, "testuser") + }) + Convey("It should contain the repo name", func() { + metas := repo.ComposeMetas() + So(metas["repo"], ShouldEqual, "testrepo") + }) + Convey("It should contain the URL format", func() { + metas := repo.ComposeMetas() + So(metas["format"], ShouldEqual, "https://someurl.com/{user}/{repo}/{issue}") + }) + }) + }) +} diff --git a/internal/db/ssh_key.go b/internal/db/ssh_key.go new file mode 100644 index 00000000..317d90d3 --- /dev/null +++ b/internal/db/ssh_key.go @@ -0,0 +1,771 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package db + +import ( + "encoding/base64" + "encoding/binary" + "errors" + "fmt" + "io/ioutil" + "math/big" + "os" + "path" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/unknwon/com" + "golang.org/x/crypto/ssh" + log "gopkg.in/clog.v1" + "xorm.io/xorm" + + "gogs.io/gogs/internal/process" + "gogs.io/gogs/internal/setting" +) + +const ( + _TPL_PUBLICK_KEY = `command="%s serv key-%d --config='%s'",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty %s` + "\n" +) + +var sshOpLocker sync.Mutex + +type KeyType int + +const ( + KEY_TYPE_USER = iota + 1 + KEY_TYPE_DEPLOY +) + +// PublicKey represents a user or deploy SSH public key. +type PublicKey struct { + ID int64 + OwnerID int64 `xorm:"INDEX NOT NULL"` + Name string `xorm:"NOT NULL"` + Fingerprint string `xorm:"NOT NULL"` + Content string `xorm:"TEXT NOT NULL"` + Mode AccessMode `xorm:"NOT NULL DEFAULT 2"` + Type KeyType `xorm:"NOT NULL DEFAULT 1"` + + Created time.Time `xorm:"-" json:"-"` + CreatedUnix int64 + Updated time.Time `xorm:"-" json:"-"` // Note: Updated must below Created for AfterSet. + UpdatedUnix int64 + HasRecentActivity bool `xorm:"-" json:"-"` + HasUsed bool `xorm:"-" json:"-"` +} + +func (k *PublicKey) BeforeInsert() { + k.CreatedUnix = time.Now().Unix() +} + +func (k *PublicKey) BeforeUpdate() { + k.UpdatedUnix = time.Now().Unix() +} + +func (k *PublicKey) AfterSet(colName string, _ xorm.Cell) { + switch colName { + case "created_unix": + k.Created = time.Unix(k.CreatedUnix, 0).Local() + case "updated_unix": + k.Updated = time.Unix(k.UpdatedUnix, 0).Local() + k.HasUsed = k.Updated.After(k.Created) + k.HasRecentActivity = k.Updated.Add(7 * 24 * time.Hour).After(time.Now()) + } +} + +// OmitEmail returns content of public key without email address. +func (k *PublicKey) OmitEmail() string { + return strings.Join(strings.Split(k.Content, " ")[:2], " ") +} + +// AuthorizedString returns formatted public key string for authorized_keys file. +func (k *PublicKey) AuthorizedString() string { + return fmt.Sprintf(_TPL_PUBLICK_KEY, setting.AppPath, k.ID, setting.CustomConf, k.Content) +} + +// IsDeployKey returns true if the public key is used as deploy key. +func (k *PublicKey) IsDeployKey() bool { + return k.Type == KEY_TYPE_DEPLOY +} + +func extractTypeFromBase64Key(key string) (string, error) { + b, err := base64.StdEncoding.DecodeString(key) + if err != nil || len(b) < 4 { + return "", fmt.Errorf("invalid key format: %v", err) + } + + keyLength := int(binary.BigEndian.Uint32(b)) + if len(b) < 4+keyLength { + return "", fmt.Errorf("invalid key format: not enough length %d", keyLength) + } + + return string(b[4 : 4+keyLength]), nil +} + +// parseKeyString parses any key string in OpenSSH or SSH2 format to clean OpenSSH string (RFC4253). +func parseKeyString(content string) (string, error) { + // Transform all legal line endings to a single "\n" + + // Replace all windows full new lines ("\r\n") + content = strings.Replace(content, "\r\n", "\n", -1) + + // Replace all windows half new lines ("\r"), if it happen not to match replace above + content = strings.Replace(content, "\r", "\n", -1) + + // Replace ending new line as its may cause unwanted behaviour (extra line means not a single line key | OpenSSH key) + content = strings.TrimRight(content, "\n") + + // split lines + lines := strings.Split(content, "\n") + + var keyType, keyContent, keyComment string + + if len(lines) == 1 { + // Parse OpenSSH format. + parts := strings.SplitN(lines[0], " ", 3) + switch len(parts) { + case 0: + return "", errors.New("empty key") + case 1: + keyContent = parts[0] + case 2: + keyType = parts[0] + keyContent = parts[1] + default: + keyType = parts[0] + keyContent = parts[1] + keyComment = parts[2] + } + + // If keyType is not given, extract it from content. If given, validate it. + t, err := extractTypeFromBase64Key(keyContent) + if err != nil { + return "", fmt.Errorf("extractTypeFromBase64Key: %v", err) + } + if len(keyType) == 0 { + keyType = t + } else if keyType != t { + return "", fmt.Errorf("key type and content does not match: %s - %s", keyType, t) + } + } else { + // Parse SSH2 file format. + continuationLine := false + + for _, line := range lines { + // Skip lines that: + // 1) are a continuation of the previous line, + // 2) contain ":" as that are comment lines + // 3) contain "-" as that are begin and end tags + if continuationLine || strings.ContainsAny(line, ":-") { + continuationLine = strings.HasSuffix(line, "\\") + } else { + keyContent = keyContent + line + } + } + + t, err := extractTypeFromBase64Key(keyContent) + if err != nil { + return "", fmt.Errorf("extractTypeFromBase64Key: %v", err) + } + keyType = t + } + return keyType + " " + keyContent + " " + keyComment, nil +} + +// writeTmpKeyFile writes key content to a temporary file +// and returns the name of that file, along with any possible errors. +func writeTmpKeyFile(content string) (string, error) { + tmpFile, err := ioutil.TempFile(setting.SSH.KeyTestPath, "gogs_keytest") + if err != nil { + return "", fmt.Errorf("TempFile: %v", err) + } + defer tmpFile.Close() + + if _, err = tmpFile.WriteString(content); err != nil { + return "", fmt.Errorf("WriteString: %v", err) + } + return tmpFile.Name(), nil +} + +// SSHKeyGenParsePublicKey extracts key type and length using ssh-keygen. +func SSHKeyGenParsePublicKey(key string) (string, int, error) { + tmpName, err := writeTmpKeyFile(key) + if err != nil { + return "", 0, fmt.Errorf("writeTmpKeyFile: %v", err) + } + defer os.Remove(tmpName) + + stdout, stderr, err := process.Exec("SSHKeyGenParsePublicKey", setting.SSH.KeygenPath, "-lf", tmpName) + if err != nil { + return "", 0, fmt.Errorf("fail to parse public key: %s - %s", err, stderr) + } + if strings.Contains(stdout, "is not a public key file") { + return "", 0, ErrKeyUnableVerify{stdout} + } + + fields := strings.Split(stdout, " ") + if len(fields) < 4 { + return "", 0, fmt.Errorf("invalid public key line: %s", stdout) + } + + keyType := strings.Trim(fields[len(fields)-1], "()\r\n") + return strings.ToLower(keyType), com.StrTo(fields[0]).MustInt(), nil +} + +// SSHNativeParsePublicKey extracts the key type and length using the golang SSH library. +func SSHNativeParsePublicKey(keyLine string) (string, int, error) { + fields := strings.Fields(keyLine) + if len(fields) < 2 { + return "", 0, fmt.Errorf("not enough fields in public key line: %s", string(keyLine)) + } + + raw, err := base64.StdEncoding.DecodeString(fields[1]) + if err != nil { + return "", 0, err + } + + pkey, err := ssh.ParsePublicKey(raw) + if err != nil { + if strings.Contains(err.Error(), "ssh: unknown key algorithm") { + return "", 0, ErrKeyUnableVerify{err.Error()} + } + return "", 0, fmt.Errorf("ParsePublicKey: %v", err) + } + + // The ssh library can parse the key, so next we find out what key exactly we have. + switch pkey.Type() { + case ssh.KeyAlgoDSA: + rawPub := struct { + Name string + P, Q, G, Y *big.Int + }{} + if err := ssh.Unmarshal(pkey.Marshal(), &rawPub); err != nil { + return "", 0, err + } + // as per https://bugzilla.mindrot.org/show_bug.cgi?id=1647 we should never + // see dsa keys != 1024 bit, but as it seems to work, we will not check here + return "dsa", rawPub.P.BitLen(), nil // use P as per crypto/dsa/dsa.go (is L) + case ssh.KeyAlgoRSA: + rawPub := struct { + Name string + E *big.Int + N *big.Int + }{} + if err := ssh.Unmarshal(pkey.Marshal(), &rawPub); err != nil { + return "", 0, err + } + return "rsa", rawPub.N.BitLen(), nil // use N as per crypto/rsa/rsa.go (is bits) + case ssh.KeyAlgoECDSA256: + return "ecdsa", 256, nil + case ssh.KeyAlgoECDSA384: + return "ecdsa", 384, nil + case ssh.KeyAlgoECDSA521: + return "ecdsa", 521, nil + case ssh.KeyAlgoED25519: + return "ed25519", 256, nil + } + return "", 0, fmt.Errorf("unsupported key length detection for type: %s", pkey.Type()) +} + +// CheckPublicKeyString checks if the given public key string is recognized by SSH. +// It returns the actual public key line on success. +func CheckPublicKeyString(content string) (_ string, err error) { + if setting.SSH.Disabled { + return "", errors.New("SSH is disabled") + } + + content, err = parseKeyString(content) + if err != nil { + return "", err + } + + content = strings.TrimRight(content, "\n\r") + if strings.ContainsAny(content, "\n\r") { + return "", errors.New("only a single line with a single key please") + } + + // Remove any unnecessary whitespace + content = strings.TrimSpace(content) + + if !setting.SSH.MinimumKeySizeCheck { + return content, nil + } + + var ( + fnName string + keyType string + length int + ) + if setting.SSH.StartBuiltinServer { + fnName = "SSHNativeParsePublicKey" + keyType, length, err = SSHNativeParsePublicKey(content) + } else { + fnName = "SSHKeyGenParsePublicKey" + keyType, length, err = SSHKeyGenParsePublicKey(content) + } + if err != nil { + return "", fmt.Errorf("%s: %v", fnName, err) + } + log.Trace("Key info [native: %v]: %s-%d", setting.SSH.StartBuiltinServer, keyType, length) + + if minLen, found := setting.SSH.MinimumKeySizes[keyType]; found && length >= minLen { + return content, nil + } else if found && length < minLen { + return "", fmt.Errorf("key length is not enough: got %d, needs %d", length, minLen) + } + return "", fmt.Errorf("key type is not allowed: %s", keyType) +} + +// appendAuthorizedKeysToFile appends new SSH keys' content to authorized_keys file. +func appendAuthorizedKeysToFile(keys ...*PublicKey) error { + sshOpLocker.Lock() + defer sshOpLocker.Unlock() + + fpath := filepath.Join(setting.SSH.RootPath, "authorized_keys") + f, err := os.OpenFile(fpath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) + if err != nil { + return err + } + defer f.Close() + + // Note: chmod command does not support in Windows. + if !setting.IsWindows { + fi, err := f.Stat() + if err != nil { + return err + } + + // .ssh directory should have mode 700, and authorized_keys file should have mode 600. + if fi.Mode().Perm() > 0600 { + log.Error(4, "authorized_keys file has unusual permission flags: %s - setting to -rw-------", fi.Mode().Perm().String()) + if err = f.Chmod(0600); err != nil { + return err + } + } + } + + for _, key := range keys { + if _, err = f.WriteString(key.AuthorizedString()); err != nil { + return err + } + } + return nil +} + +// checkKeyContent onlys checks if key content has been used as public key, +// it is OK to use same key as deploy key for multiple repositories/users. +func checkKeyContent(content string) error { + has, err := x.Get(&PublicKey{ + Content: content, + Type: KEY_TYPE_USER, + }) + if err != nil { + return err + } else if has { + return ErrKeyAlreadyExist{0, content} + } + return nil +} + +func addKey(e Engine, key *PublicKey) (err error) { + // Calculate fingerprint. + tmpPath := strings.Replace(path.Join(os.TempDir(), fmt.Sprintf("%d", time.Now().Nanosecond()), + "id_rsa.pub"), "\\", "/", -1) + os.MkdirAll(path.Dir(tmpPath), os.ModePerm) + if err = ioutil.WriteFile(tmpPath, []byte(key.Content), 0644); err != nil { + return err + } + + stdout, stderr, err := process.Exec("AddPublicKey", setting.SSH.KeygenPath, "-lf", tmpPath) + if err != nil { + return fmt.Errorf("fail to parse public key: %s - %s", err, stderr) + } else if len(stdout) < 2 { + return errors.New("not enough output for calculating fingerprint: " + stdout) + } + key.Fingerprint = strings.Split(stdout, " ")[1] + + // Save SSH key. + if _, err = e.Insert(key); err != nil { + return err + } + + // Don't need to rewrite this file if builtin SSH server is enabled. + if setting.SSH.StartBuiltinServer { + return nil + } + return appendAuthorizedKeysToFile(key) +} + +// AddPublicKey adds new public key to database and authorized_keys file. +func AddPublicKey(ownerID int64, name, content string) (*PublicKey, error) { + log.Trace(content) + if err := checkKeyContent(content); err != nil { + return nil, err + } + + // Key name of same user cannot be duplicated. + has, err := x.Where("owner_id = ? AND name = ?", ownerID, name).Get(new(PublicKey)) + if err != nil { + return nil, err + } else if has { + return nil, ErrKeyNameAlreadyUsed{ownerID, name} + } + + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return nil, err + } + + key := &PublicKey{ + OwnerID: ownerID, + Name: name, + Content: content, + Mode: ACCESS_MODE_WRITE, + Type: KEY_TYPE_USER, + } + if err = addKey(sess, key); err != nil { + return nil, fmt.Errorf("addKey: %v", err) + } + + return key, sess.Commit() +} + +// GetPublicKeyByID returns public key by given ID. +func GetPublicKeyByID(keyID int64) (*PublicKey, error) { + key := new(PublicKey) + has, err := x.Id(keyID).Get(key) + if err != nil { + return nil, err + } else if !has { + return nil, ErrKeyNotExist{keyID} + } + return key, nil +} + +// SearchPublicKeyByContent searches content as prefix (leak e-mail part) +// and returns public key found. +func SearchPublicKeyByContent(content string) (*PublicKey, error) { + key := new(PublicKey) + has, err := x.Where("content like ?", content+"%").Get(key) + if err != nil { + return nil, err + } else if !has { + return nil, ErrKeyNotExist{} + } + return key, nil +} + +// ListPublicKeys returns a list of public keys belongs to given user. +func ListPublicKeys(uid int64) ([]*PublicKey, error) { + keys := make([]*PublicKey, 0, 5) + return keys, x.Where("owner_id = ?", uid).Find(&keys) +} + +// UpdatePublicKey updates given public key. +func UpdatePublicKey(key *PublicKey) error { + _, err := x.Id(key.ID).AllCols().Update(key) + return err +} + +// deletePublicKeys does the actual key deletion but does not update authorized_keys file. +func deletePublicKeys(e *xorm.Session, keyIDs ...int64) error { + if len(keyIDs) == 0 { + return nil + } + + _, err := e.In("id", keyIDs).Delete(new(PublicKey)) + return err +} + +// DeletePublicKey deletes SSH key information both in database and authorized_keys file. +func DeletePublicKey(doer *User, id int64) (err error) { + key, err := GetPublicKeyByID(id) + if err != nil { + if IsErrKeyNotExist(err) { + return nil + } + return fmt.Errorf("GetPublicKeyByID: %v", err) + } + + // Check if user has access to delete this key. + if !doer.IsAdmin && doer.ID != key.OwnerID { + return ErrKeyAccessDenied{doer.ID, key.ID, "public"} + } + + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return err + } + + if err = deletePublicKeys(sess, id); err != nil { + return err + } + + if err = sess.Commit(); err != nil { + return err + } + + return RewriteAuthorizedKeys() +} + +// RewriteAuthorizedKeys removes any authorized key and rewrite all keys from database again. +// Note: x.Iterate does not get latest data after insert/delete, so we have to call this function +// outsite any session scope independently. +func RewriteAuthorizedKeys() error { + sshOpLocker.Lock() + defer sshOpLocker.Unlock() + + log.Trace("Doing: RewriteAuthorizedKeys") + + os.MkdirAll(setting.SSH.RootPath, os.ModePerm) + fpath := filepath.Join(setting.SSH.RootPath, "authorized_keys") + tmpPath := fpath + ".tmp" + f, err := os.OpenFile(tmpPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return err + } + defer os.Remove(tmpPath) + + err = x.Iterate(new(PublicKey), func(idx int, bean interface{}) (err error) { + _, err = f.WriteString((bean.(*PublicKey)).AuthorizedString()) + return err + }) + f.Close() + if err != nil { + return err + } + + if com.IsExist(fpath) { + if err = os.Remove(fpath); err != nil { + return err + } + } + if err = os.Rename(tmpPath, fpath); err != nil { + return err + } + + return nil +} + +// ________ .__ ____ __. +// \______ \ ____ ______ | | ____ ___.__.| |/ _|____ ___.__. +// | | \_/ __ \\____ \| | / _ < | || <_/ __ < | | +// | ` \ ___/| |_> > |_( <_> )___ || | \ ___/\___ | +// /_______ /\___ > __/|____/\____// ____||____|__ \___ > ____| +// \/ \/|__| \/ \/ \/\/ + +// DeployKey represents deploy key information and its relation with repository. +type DeployKey struct { + ID int64 + KeyID int64 `xorm:"UNIQUE(s) INDEX"` + RepoID int64 `xorm:"UNIQUE(s) INDEX"` + Name string + Fingerprint string + Content string `xorm:"-" json:"-"` + + Created time.Time `xorm:"-" json:"-"` + CreatedUnix int64 + Updated time.Time `xorm:"-" json:"-"` // Note: Updated must below Created for AfterSet. + UpdatedUnix int64 + HasRecentActivity bool `xorm:"-" json:"-"` + HasUsed bool `xorm:"-" json:"-"` +} + +func (k *DeployKey) BeforeInsert() { + k.CreatedUnix = time.Now().Unix() +} + +func (k *DeployKey) BeforeUpdate() { + k.UpdatedUnix = time.Now().Unix() +} + +func (k *DeployKey) AfterSet(colName string, _ xorm.Cell) { + switch colName { + case "created_unix": + k.Created = time.Unix(k.CreatedUnix, 0).Local() + case "updated_unix": + k.Updated = time.Unix(k.UpdatedUnix, 0).Local() + k.HasUsed = k.Updated.After(k.Created) + k.HasRecentActivity = k.Updated.Add(7 * 24 * time.Hour).After(time.Now()) + } +} + +// GetContent gets associated public key content. +func (k *DeployKey) GetContent() error { + pkey, err := GetPublicKeyByID(k.KeyID) + if err != nil { + return err + } + k.Content = pkey.Content + return nil +} + +func checkDeployKey(e Engine, keyID, repoID int64, name string) error { + // Note: We want error detail, not just true or false here. + has, err := e.Where("key_id = ? AND repo_id = ?", keyID, repoID).Get(new(DeployKey)) + if err != nil { + return err + } else if has { + return ErrDeployKeyAlreadyExist{keyID, repoID} + } + + has, err = e.Where("repo_id = ? AND name = ?", repoID, name).Get(new(DeployKey)) + if err != nil { + return err + } else if has { + return ErrDeployKeyNameAlreadyUsed{repoID, name} + } + + return nil +} + +// addDeployKey adds new key-repo relation. +func addDeployKey(e *xorm.Session, keyID, repoID int64, name, fingerprint string) (*DeployKey, error) { + if err := checkDeployKey(e, keyID, repoID, name); err != nil { + return nil, err + } + + key := &DeployKey{ + KeyID: keyID, + RepoID: repoID, + Name: name, + Fingerprint: fingerprint, + } + _, err := e.Insert(key) + return key, err +} + +// HasDeployKey returns true if public key is a deploy key of given repository. +func HasDeployKey(keyID, repoID int64) bool { + has, _ := x.Where("key_id = ? AND repo_id = ?", keyID, repoID).Get(new(DeployKey)) + return has +} + +// AddDeployKey add new deploy key to database and authorized_keys file. +func AddDeployKey(repoID int64, name, content string) (*DeployKey, error) { + if err := checkKeyContent(content); err != nil { + return nil, err + } + + pkey := &PublicKey{ + Content: content, + Mode: ACCESS_MODE_READ, + Type: KEY_TYPE_DEPLOY, + } + has, err := x.Get(pkey) + if err != nil { + return nil, err + } + + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return nil, err + } + + // First time use this deploy key. + if !has { + if err = addKey(sess, pkey); err != nil { + return nil, fmt.Errorf("addKey: %v", err) + } + } + + key, err := addDeployKey(sess, pkey.ID, repoID, name, pkey.Fingerprint) + if err != nil { + return nil, fmt.Errorf("addDeployKey: %v", err) + } + + return key, sess.Commit() +} + +// GetDeployKeyByID returns deploy key by given ID. +func GetDeployKeyByID(id int64) (*DeployKey, error) { + key := new(DeployKey) + has, err := x.Id(id).Get(key) + if err != nil { + return nil, err + } else if !has { + return nil, ErrDeployKeyNotExist{id, 0, 0} + } + return key, nil +} + +// GetDeployKeyByRepo returns deploy key by given public key ID and repository ID. +func GetDeployKeyByRepo(keyID, repoID int64) (*DeployKey, error) { + key := &DeployKey{ + KeyID: keyID, + RepoID: repoID, + } + has, err := x.Get(key) + if err != nil { + return nil, err + } else if !has { + return nil, ErrDeployKeyNotExist{0, keyID, repoID} + } + return key, nil +} + +// UpdateDeployKey updates deploy key information. +func UpdateDeployKey(key *DeployKey) error { + _, err := x.Id(key.ID).AllCols().Update(key) + return err +} + +// DeleteDeployKey deletes deploy key from its repository authorized_keys file if needed. +func DeleteDeployKey(doer *User, id int64) error { + key, err := GetDeployKeyByID(id) + if err != nil { + if IsErrDeployKeyNotExist(err) { + return nil + } + return fmt.Errorf("GetDeployKeyByID: %v", err) + } + + // Check if user has access to delete this key. + if !doer.IsAdmin { + repo, err := GetRepositoryByID(key.RepoID) + if err != nil { + return fmt.Errorf("GetRepositoryByID: %v", err) + } + yes, err := HasAccess(doer.ID, repo, ACCESS_MODE_ADMIN) + if err != nil { + return fmt.Errorf("HasAccess: %v", err) + } else if !yes { + return ErrKeyAccessDenied{doer.ID, key.ID, "deploy"} + } + } + + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return err + } + + if _, err = sess.ID(key.ID).Delete(new(DeployKey)); err != nil { + return fmt.Errorf("delete deploy key [%d]: %v", key.ID, err) + } + + // Check if this is the last reference to same key content. + has, err := sess.Where("key_id = ?", key.KeyID).Get(new(DeployKey)) + if err != nil { + return err + } else if !has { + if err = deletePublicKeys(sess, key.KeyID); err != nil { + return err + } + } + + return sess.Commit() +} + +// ListDeployKeys returns all deploy keys by given repository ID. +func ListDeployKeys(repoID int64) ([]*DeployKey, error) { + keys := make([]*DeployKey, 0, 5) + return keys, x.Where("repo_id = ?", repoID).Find(&keys) +} diff --git a/internal/db/ssh_key_test.go b/internal/db/ssh_key_test.go new file mode 100644 index 00000000..d4c06488 --- /dev/null +++ b/internal/db/ssh_key_test.go @@ -0,0 +1,56 @@ +// Copyright 2016 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package db + +import ( + "fmt" + "strings" + "testing" + + . "github.com/smartystreets/goconvey/convey" + + "gogs.io/gogs/internal/setting" +) + +func init() { + setting.NewContext() +} + +func Test_SSHParsePublicKey(t *testing.T) { + testKeys := map[string]struct { + typeName string + length int + content string + }{ + "dsa-1024": {"dsa", 1024, "ssh-dss AAAAB3NzaC1kc3MAAACBAOChCC7lf6Uo9n7BmZ6M8St19PZf4Tn59NriyboW2x/DZuYAz3ibZ2OkQ3S0SqDIa0HXSEJ1zaExQdmbO+Ux/wsytWZmCczWOVsaszBZSl90q8UnWlSH6P+/YA+RWJm5SFtuV9PtGIhyZgoNuz5kBQ7K139wuQsecdKktISwTakzAAAAFQCzKsO2JhNKlL+wwwLGOcLffoAmkwAAAIBpK7/3xvduajLBD/9vASqBQIHrgK2J+wiQnIb/Wzy0UsVmvfn8A+udRbBo+csM8xrSnlnlJnjkJS3qiM5g+eTwsLIV1IdKPEwmwB+VcP53Cw6lSyWyJcvhFb0N6s08NZysLzvj0N+ZC/FnhKTLzIyMtkHf/IrPCwlM+pV/M/96YgAAAIEAqQcGn9CKgzgPaguIZooTAOQdvBLMI5y0bQjOW6734XOpqQGf/Kra90wpoasLKZjSYKNPjE+FRUOrStLrxcNs4BeVKhy2PYTRnybfYVk1/dmKgH6P1YSRONsGKvTsH6c5IyCRG0ncCgYeF8tXppyd642982daopE7zQ/NPAnJfag= nocomment"}, + "rsa-1024": {"rsa", 1024, "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQDAu7tvIvX6ZHrRXuZNfkR3XLHSsuCK9Zn3X58lxBcQzuo5xZgB6vRwwm/QtJuF+zZPtY5hsQILBLmF+BZ5WpKZp1jBeSjH2G7lxet9kbcH+kIVj0tPFEoyKI9wvWqIwC4prx/WVk2wLTJjzBAhyNxfEq7C9CeiX9pQEbEqJfkKCQ== nocomment\n"}, + "rsa-2048": {"rsa", 2048, "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDMZXh+1OBUwSH9D45wTaxErQIN9IoC9xl7MKJkqvTvv6O5RR9YW/IK9FbfjXgXsppYGhsCZo1hFOOsXHMnfOORqu/xMDx4yPuyvKpw4LePEcg4TDipaDFuxbWOqc/BUZRZcXu41QAWfDLrInwsltWZHSeG7hjhpacl4FrVv9V1pS6Oc5Q1NxxEzTzuNLS/8diZrTm/YAQQ/+B+mzWI3zEtF4miZjjAljWd1LTBPvU23d29DcBmmFahcZ441XZsTeAwGxG/Q6j8NgNXj9WxMeWwxXV2jeAX/EBSpZrCVlCQ1yJswT6xCp8TuBnTiGWYMBNTbOZvPC4e0WI2/yZW/s5F nocomment"}, + "ecdsa-256": {"ecdsa", 256, "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBFQacN3PrOll7PXmN5B/ZNVahiUIqI05nbBlZk1KXsO3d06ktAWqbNflv2vEmA38bTFTfJ2sbn2B5ksT52cDDbA= nocomment"}, + "ecdsa-384": {"ecdsa", 384, "ecdsa-sha2-nistp384 AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBINmioV+XRX1Fm9Qk2ehHXJ2tfVxW30ypUWZw670Zyq5GQfBAH6xjygRsJ5wWsHXBsGYgFUXIHvMKVAG1tpw7s6ax9oA+dJOJ7tj+vhn8joFqT+sg3LYHgZkHrfqryRasQ== nocomment"}, + // "ecdsa-521": {"ecdsa", 521, "ecdsa-sha2-nistp521 AAAAE2VjZHNhLXNoYTItbmlzdHA1MjEAAAAIbmlzdHA1MjEAAACFBACGt3UG3EzRwNOI17QR84l6PgiAcvCE7v6aXPj/SC6UWKg4EL8vW9ZBcdYL9wzs4FZXh4MOV8jAzu3KRWNTwb4k2wFNUpGOt7l28MztFFEtH5BDDrtAJSPENPy8pvPLMfnPg5NhvWycqIBzNcHipem5wSJFN5PdpNOC2xMrPWKNqj+ZjQ== nocomment"}, + } + + Convey("Parse public keys in both native and ssh-keygen", t, func() { + for name, key := range testKeys { + fmt.Println("\nTesting key:", name) + + keyTypeN, lengthN, errN := SSHNativeParsePublicKey(key.content) + So(errN, ShouldBeNil) + So(keyTypeN, ShouldEqual, key.typeName) + So(lengthN, ShouldEqual, key.length) + + keyTypeK, lengthK, errK := SSHKeyGenParsePublicKey(key.content) + if errK != nil { + // Some server just does not support ecdsa format. + if strings.Contains(errK.Error(), "line 1 too long:") { + continue + } + So(errK, ShouldBeNil) + } + So(keyTypeK, ShouldEqual, key.typeName) + So(lengthK, ShouldEqual, key.length) + } + }) +} diff --git a/internal/db/token.go b/internal/db/token.go new file mode 100644 index 00000000..2e2f3492 --- /dev/null +++ b/internal/db/token.go @@ -0,0 +1,102 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package db + +import ( + "time" + + gouuid "github.com/satori/go.uuid" + "xorm.io/xorm" + + "gogs.io/gogs/internal/db/errors" + "gogs.io/gogs/internal/tool" +) + +// AccessToken represents a personal access token. +type AccessToken struct { + ID int64 + UID int64 `xorm:"INDEX"` + Name string + Sha1 string `xorm:"UNIQUE VARCHAR(40)"` + + Created time.Time `xorm:"-" json:"-"` + CreatedUnix int64 + Updated time.Time `xorm:"-" json:"-"` // Note: Updated must below Created for AfterSet. + UpdatedUnix int64 + HasRecentActivity bool `xorm:"-" json:"-"` + HasUsed bool `xorm:"-" json:"-"` +} + +func (t *AccessToken) BeforeInsert() { + t.CreatedUnix = time.Now().Unix() +} + +func (t *AccessToken) BeforeUpdate() { + t.UpdatedUnix = time.Now().Unix() +} + +func (t *AccessToken) AfterSet(colName string, _ xorm.Cell) { + switch colName { + case "created_unix": + t.Created = time.Unix(t.CreatedUnix, 0).Local() + case "updated_unix": + t.Updated = time.Unix(t.UpdatedUnix, 0).Local() + t.HasUsed = t.Updated.After(t.Created) + t.HasRecentActivity = t.Updated.Add(7 * 24 * time.Hour).After(time.Now()) + } +} + +// NewAccessToken creates new access token. +func NewAccessToken(t *AccessToken) error { + t.Sha1 = tool.SHA1(gouuid.NewV4().String()) + has, err := x.Get(&AccessToken{ + UID: t.UID, + Name: t.Name, + }) + if err != nil { + return err + } else if has { + return errors.AccessTokenNameAlreadyExist{t.Name} + } + + _, err = x.Insert(t) + return err +} + +// GetAccessTokenBySHA returns access token by given sha1. +func GetAccessTokenBySHA(sha string) (*AccessToken, error) { + if sha == "" { + return nil, ErrAccessTokenEmpty{} + } + t := &AccessToken{Sha1: sha} + has, err := x.Get(t) + if err != nil { + return nil, err + } else if !has { + return nil, ErrAccessTokenNotExist{sha} + } + return t, nil +} + +// ListAccessTokens returns a list of access tokens belongs to given user. +func ListAccessTokens(uid int64) ([]*AccessToken, error) { + tokens := make([]*AccessToken, 0, 5) + return tokens, x.Where("uid=?", uid).Desc("id").Find(&tokens) +} + +// UpdateAccessToken updates information of access token. +func UpdateAccessToken(t *AccessToken) error { + _, err := x.Id(t.ID).AllCols().Update(t) + return err +} + +// DeleteAccessTokenOfUserByID deletes access token by given ID. +func DeleteAccessTokenOfUserByID(userID, id int64) error { + _, err := x.Delete(&AccessToken{ + ID: id, + UID: userID, + }) + return err +} diff --git a/internal/db/two_factor.go b/internal/db/two_factor.go new file mode 100644 index 00000000..dcc1c16c --- /dev/null +++ b/internal/db/two_factor.go @@ -0,0 +1,201 @@ +// Copyright 2017 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package db + +import ( + "encoding/base64" + "fmt" + "strings" + "time" + + "github.com/pquerna/otp/totp" + "github.com/unknwon/com" + log "gopkg.in/clog.v1" + "xorm.io/xorm" + + "gogs.io/gogs/internal/db/errors" + "gogs.io/gogs/internal/setting" + "gogs.io/gogs/internal/tool" +) + +// TwoFactor represents a two-factor authentication token. +type TwoFactor struct { + ID int64 + UserID int64 `xorm:"UNIQUE"` + Secret string + Created time.Time `xorm:"-" json:"-"` + CreatedUnix int64 +} + +func (t *TwoFactor) BeforeInsert() { + t.CreatedUnix = time.Now().Unix() +} + +func (t *TwoFactor) AfterSet(colName string, _ xorm.Cell) { + switch colName { + case "created_unix": + t.Created = time.Unix(t.CreatedUnix, 0).Local() + } +} + +// ValidateTOTP returns true if given passcode is valid for two-factor authentication token. +// It also returns possible validation error. +func (t *TwoFactor) ValidateTOTP(passcode string) (bool, error) { + secret, err := base64.StdEncoding.DecodeString(t.Secret) + if err != nil { + return false, fmt.Errorf("DecodeString: %v", err) + } + decryptSecret, err := com.AESGCMDecrypt(tool.MD5Bytes(setting.SecretKey), secret) + if err != nil { + return false, fmt.Errorf("AESGCMDecrypt: %v", err) + } + return totp.Validate(passcode, string(decryptSecret)), nil +} + +// IsUserEnabledTwoFactor returns true if user has enabled two-factor authentication. +func IsUserEnabledTwoFactor(userID int64) bool { + has, err := x.Where("user_id = ?", userID).Get(new(TwoFactor)) + if err != nil { + log.Error(2, "IsUserEnabledTwoFactor [user_id: %d]: %v", userID, err) + } + return has +} + +func generateRecoveryCodes(userID int64) ([]*TwoFactorRecoveryCode, error) { + recoveryCodes := make([]*TwoFactorRecoveryCode, 10) + for i := 0; i < 10; i++ { + code, err := tool.RandomString(10) + if err != nil { + return nil, fmt.Errorf("RandomString: %v", err) + } + recoveryCodes[i] = &TwoFactorRecoveryCode{ + UserID: userID, + Code: strings.ToLower(code[:5] + "-" + code[5:]), + } + } + return recoveryCodes, nil +} + +// NewTwoFactor creates a new two-factor authentication token and recovery codes for given user. +func NewTwoFactor(userID int64, secret string) error { + t := &TwoFactor{ + UserID: userID, + } + + // Encrypt secret + encryptSecret, err := com.AESGCMEncrypt(tool.MD5Bytes(setting.SecretKey), []byte(secret)) + if err != nil { + return fmt.Errorf("AESGCMEncrypt: %v", err) + } + t.Secret = base64.StdEncoding.EncodeToString(encryptSecret) + + recoveryCodes, err := generateRecoveryCodes(userID) + if err != nil { + return fmt.Errorf("generateRecoveryCodes: %v", err) + } + + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return err + } + + if _, err = sess.Insert(t); err != nil { + return fmt.Errorf("insert two-factor: %v", err) + } else if _, err = sess.Insert(recoveryCodes); err != nil { + return fmt.Errorf("insert recovery codes: %v", err) + } + + return sess.Commit() +} + +// GetTwoFactorByUserID returns two-factor authentication token of given user. +func GetTwoFactorByUserID(userID int64) (*TwoFactor, error) { + t := new(TwoFactor) + has, err := x.Where("user_id = ?", userID).Get(t) + if err != nil { + return nil, err + } else if !has { + return nil, errors.TwoFactorNotFound{userID} + } + + return t, nil +} + +// DeleteTwoFactor removes two-factor authentication token and recovery codes of given user. +func DeleteTwoFactor(userID int64) (err error) { + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return err + } + + if _, err = sess.Where("user_id = ?", userID).Delete(new(TwoFactor)); err != nil { + return fmt.Errorf("delete two-factor: %v", err) + } else if err = deleteRecoveryCodesByUserID(sess, userID); err != nil { + return fmt.Errorf("deleteRecoveryCodesByUserID: %v", err) + } + + return sess.Commit() +} + +// TwoFactorRecoveryCode represents a two-factor authentication recovery code. +type TwoFactorRecoveryCode struct { + ID int64 + UserID int64 + Code string `xorm:"VARCHAR(11)"` + IsUsed bool +} + +// GetRecoveryCodesByUserID returns all recovery codes of given user. +func GetRecoveryCodesByUserID(userID int64) ([]*TwoFactorRecoveryCode, error) { + recoveryCodes := make([]*TwoFactorRecoveryCode, 0, 10) + return recoveryCodes, x.Where("user_id = ?", userID).Find(&recoveryCodes) +} + +func deleteRecoveryCodesByUserID(e Engine, userID int64) error { + _, err := e.Where("user_id = ?", userID).Delete(new(TwoFactorRecoveryCode)) + return err +} + +// RegenerateRecoveryCodes regenerates new set of recovery codes for given user. +func RegenerateRecoveryCodes(userID int64) error { + recoveryCodes, err := generateRecoveryCodes(userID) + if err != nil { + return fmt.Errorf("generateRecoveryCodes: %v", err) + } + + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return err + } + + if err = deleteRecoveryCodesByUserID(sess, userID); err != nil { + return fmt.Errorf("deleteRecoveryCodesByUserID: %v", err) + } else if _, err = sess.Insert(recoveryCodes); err != nil { + return fmt.Errorf("insert new recovery codes: %v", err) + } + + return sess.Commit() +} + +// UseRecoveryCode validates recovery code of given user and marks it is used if valid. +func UseRecoveryCode(userID int64, code string) error { + recoveryCode := new(TwoFactorRecoveryCode) + has, err := x.Where("code = ?", code).And("is_used = ?", false).Get(recoveryCode) + if err != nil { + return fmt.Errorf("get unused code: %v", err) + } else if !has { + return errors.TwoFactorRecoveryCodeNotFound{code} + } + + recoveryCode.IsUsed = true + if _, err = x.Id(recoveryCode.ID).Cols("is_used").Update(recoveryCode); err != nil { + return fmt.Errorf("mark code as used: %v", err) + } + + return nil +} diff --git a/internal/db/update.go b/internal/db/update.go new file mode 100644 index 00000000..6555a479 --- /dev/null +++ b/internal/db/update.go @@ -0,0 +1,142 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package db + +import ( + "container/list" + "fmt" + "os/exec" + "strings" + + git "github.com/gogs/git-module" +) + +// CommitToPushCommit transforms a git.Commit to PushCommit type. +func CommitToPushCommit(commit *git.Commit) *PushCommit { + return &PushCommit{ + Sha1: commit.ID.String(), + Message: commit.Message(), + AuthorEmail: commit.Author.Email, + AuthorName: commit.Author.Name, + CommitterEmail: commit.Committer.Email, + CommitterName: commit.Committer.Name, + Timestamp: commit.Committer.When, + } +} + +func ListToPushCommits(l *list.List) *PushCommits { + if l == nil { + return &PushCommits{} + } + + commits := make([]*PushCommit, 0) + var actEmail string + for e := l.Front(); e != nil; e = e.Next() { + commit := e.Value.(*git.Commit) + if actEmail == "" { + actEmail = commit.Committer.Email + } + commits = append(commits, CommitToPushCommit(commit)) + } + return &PushCommits{l.Len(), commits, "", nil} +} + +type PushUpdateOptions struct { + OldCommitID string + NewCommitID string + RefFullName string + PusherID int64 + PusherName string + RepoUserName string + RepoName string +} + +// PushUpdate must be called for any push actions in order to +// generates necessary push action history feeds. +func PushUpdate(opts PushUpdateOptions) (err error) { + isNewRef := opts.OldCommitID == git.EMPTY_SHA + isDelRef := opts.NewCommitID == git.EMPTY_SHA + if isNewRef && isDelRef { + return fmt.Errorf("Old and new revisions are both %s", git.EMPTY_SHA) + } + + repoPath := RepoPath(opts.RepoUserName, opts.RepoName) + + gitUpdate := exec.Command("git", "update-server-info") + gitUpdate.Dir = repoPath + if err = gitUpdate.Run(); err != nil { + return fmt.Errorf("Fail to call 'git update-server-info': %v", err) + } + + gitRepo, err := git.OpenRepository(repoPath) + if err != nil { + return fmt.Errorf("OpenRepository: %v", err) + } + + owner, err := GetUserByName(opts.RepoUserName) + if err != nil { + return fmt.Errorf("GetUserByName: %v", err) + } + + repo, err := GetRepositoryByName(owner.ID, opts.RepoName) + if err != nil { + return fmt.Errorf("GetRepositoryByName: %v", err) + } + + if err = repo.UpdateSize(); err != nil { + return fmt.Errorf("UpdateSize: %v", err) + } + + // Push tags + if strings.HasPrefix(opts.RefFullName, git.TAG_PREFIX) { + if err := CommitRepoAction(CommitRepoActionOptions{ + PusherName: opts.PusherName, + RepoOwnerID: owner.ID, + RepoName: repo.Name, + RefFullName: opts.RefFullName, + OldCommitID: opts.OldCommitID, + NewCommitID: opts.NewCommitID, + Commits: &PushCommits{}, + }); err != nil { + return fmt.Errorf("CommitRepoAction.(tag): %v", err) + } + return nil + } + + var l *list.List + // Skip read parent commits when delete branch + if !isDelRef { + // Push new branch + newCommit, err := gitRepo.GetCommit(opts.NewCommitID) + if err != nil { + return fmt.Errorf("GetCommit [commit_id: %s]: %v", opts.NewCommitID, err) + } + + if isNewRef { + l, err = newCommit.CommitsBeforeLimit(10) + if err != nil { + return fmt.Errorf("CommitsBeforeLimit [commit_id: %s]: %v", newCommit.ID, err) + } + } else { + l, err = newCommit.CommitsBeforeUntil(opts.OldCommitID) + if err != nil { + return fmt.Errorf("CommitsBeforeUntil [commit_id: %s]: %v", opts.OldCommitID, err) + } + } + } + + if err := CommitRepoAction(CommitRepoActionOptions{ + PusherName: opts.PusherName, + RepoOwnerID: owner.ID, + RepoName: repo.Name, + RefFullName: opts.RefFullName, + OldCommitID: opts.OldCommitID, + NewCommitID: opts.NewCommitID, + Commits: ListToPushCommits(l), + }); err != nil { + return fmt.Errorf("CommitRepoAction.(branch): %v", err) + } + return nil +} diff --git a/internal/db/user.go b/internal/db/user.go new file mode 100644 index 00000000..3318a4be --- /dev/null +++ b/internal/db/user.go @@ -0,0 +1,1146 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package db + +import ( + "bytes" + "container/list" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "fmt" + "image" + _ "image/jpeg" + "image/png" + "os" + "path/filepath" + "strings" + "time" + "unicode/utf8" + + "github.com/nfnt/resize" + "github.com/unknwon/com" + "golang.org/x/crypto/pbkdf2" + log "gopkg.in/clog.v1" + "xorm.io/xorm" + + "github.com/gogs/git-module" + api "github.com/gogs/go-gogs-client" + + "gogs.io/gogs/internal/avatar" + "gogs.io/gogs/internal/db/errors" + "gogs.io/gogs/internal/setting" + "gogs.io/gogs/internal/tool" +) + +// USER_AVATAR_URL_PREFIX is used to identify a URL is to access user avatar. +const USER_AVATAR_URL_PREFIX = "avatars" + +type UserType int + +const ( + USER_TYPE_INDIVIDUAL UserType = iota // Historic reason to make it starts at 0. + USER_TYPE_ORGANIZATION +) + +// User represents the object of individual and member of organization. +type User struct { + ID int64 + LowerName string `xorm:"UNIQUE NOT NULL"` + Name string `xorm:"UNIQUE NOT NULL"` + FullName string + // Email is the primary email address (to be used for communication) + Email string `xorm:"NOT NULL"` + Passwd string `xorm:"NOT NULL"` + LoginType LoginType + LoginSource int64 `xorm:"NOT NULL DEFAULT 0"` + LoginName string + Type UserType + OwnedOrgs []*User `xorm:"-" json:"-"` + Orgs []*User `xorm:"-" json:"-"` + Repos []*Repository `xorm:"-" json:"-"` + Location string + Website string + Rands string `xorm:"VARCHAR(10)"` + Salt string `xorm:"VARCHAR(10)"` + + Created time.Time `xorm:"-" json:"-"` + CreatedUnix int64 + Updated time.Time `xorm:"-" json:"-"` + UpdatedUnix int64 + + // Remember visibility choice for convenience, true for private + LastRepoVisibility bool + // Maximum repository creation limit, -1 means use gloabl default + MaxRepoCreation int `xorm:"NOT NULL DEFAULT -1"` + + // Permissions + IsActive bool // Activate primary email + IsAdmin bool + AllowGitHook bool + AllowImportLocal bool // Allow migrate repository by local path + ProhibitLogin bool + + // Avatar + Avatar string `xorm:"VARCHAR(2048) NOT NULL"` + AvatarEmail string `xorm:"NOT NULL"` + UseCustomAvatar bool + + // Counters + NumFollowers int + NumFollowing int `xorm:"NOT NULL DEFAULT 0"` + NumStars int + NumRepos int + + // For organization + Description string + NumTeams int + NumMembers int + Teams []*Team `xorm:"-" json:"-"` + Members []*User `xorm:"-" json:"-"` +} + +func (u *User) BeforeInsert() { + u.CreatedUnix = time.Now().Unix() + u.UpdatedUnix = u.CreatedUnix +} + +func (u *User) BeforeUpdate() { + if u.MaxRepoCreation < -1 { + u.MaxRepoCreation = -1 + } + u.UpdatedUnix = time.Now().Unix() +} + +func (u *User) AfterSet(colName string, _ xorm.Cell) { + switch colName { + case "created_unix": + u.Created = time.Unix(u.CreatedUnix, 0).Local() + case "updated_unix": + u.Updated = time.Unix(u.UpdatedUnix, 0).Local() + } +} + +// IDStr returns string representation of user's ID. +func (u *User) IDStr() string { + return com.ToStr(u.ID) +} + +func (u *User) APIFormat() *api.User { + return &api.User{ + ID: u.ID, + UserName: u.Name, + Login: u.Name, + FullName: u.FullName, + Email: u.Email, + AvatarUrl: u.AvatarLink(), + } +} + +// returns true if user login type is LOGIN_PLAIN. +func (u *User) IsLocal() bool { + return u.LoginType <= LOGIN_PLAIN +} + +// HasForkedRepo checks if user has already forked a repository with given ID. +func (u *User) HasForkedRepo(repoID int64) bool { + _, has, _ := HasForkedRepo(u.ID, repoID) + return has +} + +func (u *User) RepoCreationNum() int { + if u.MaxRepoCreation <= -1 { + return setting.Repository.MaxCreationLimit + } + return u.MaxRepoCreation +} + +func (u *User) CanCreateRepo() bool { + if u.MaxRepoCreation <= -1 { + if setting.Repository.MaxCreationLimit <= -1 { + return true + } + return u.NumRepos < setting.Repository.MaxCreationLimit + } + return u.NumRepos < u.MaxRepoCreation +} + +func (u *User) CanCreateOrganization() bool { + return !setting.Admin.DisableRegularOrgCreation || u.IsAdmin +} + +// CanEditGitHook returns true if user can edit Git hooks. +func (u *User) CanEditGitHook() bool { + return u.IsAdmin || u.AllowGitHook +} + +// CanImportLocal returns true if user can migrate repository by local path. +func (u *User) CanImportLocal() bool { + return setting.Repository.EnableLocalPathMigration && (u.IsAdmin || u.AllowImportLocal) +} + +// DashboardLink returns the user dashboard page link. +func (u *User) DashboardLink() string { + if u.IsOrganization() { + return setting.AppSubURL + "/org/" + u.Name + "/dashboard/" + } + return setting.AppSubURL + "/" +} + +// HomeLink returns the user or organization home page link. +func (u *User) HomeLink() string { + return setting.AppSubURL + "/" + u.Name +} + +func (u *User) HTMLURL() string { + return setting.AppURL + u.Name +} + +// GenerateEmailActivateCode generates an activate code based on user information and given e-mail. +func (u *User) GenerateEmailActivateCode(email string) string { + code := tool.CreateTimeLimitCode( + com.ToStr(u.ID)+email+u.LowerName+u.Passwd+u.Rands, + setting.Service.ActiveCodeLives, nil) + + // Add tail hex username + code += hex.EncodeToString([]byte(u.LowerName)) + return code +} + +// GenerateActivateCode generates an activate code based on user information. +func (u *User) GenerateActivateCode() string { + return u.GenerateEmailActivateCode(u.Email) +} + +// CustomAvatarPath returns user custom avatar file path. +func (u *User) CustomAvatarPath() string { + return filepath.Join(setting.AvatarUploadPath, com.ToStr(u.ID)) +} + +// GenerateRandomAvatar generates a random avatar for user. +func (u *User) GenerateRandomAvatar() error { + seed := u.Email + if len(seed) == 0 { + seed = u.Name + } + + img, err := avatar.RandomImage([]byte(seed)) + if err != nil { + return fmt.Errorf("RandomImage: %v", err) + } + if err = os.MkdirAll(filepath.Dir(u.CustomAvatarPath()), os.ModePerm); err != nil { + return fmt.Errorf("MkdirAll: %v", err) + } + fw, err := os.Create(u.CustomAvatarPath()) + if err != nil { + return fmt.Errorf("Create: %v", err) + } + defer fw.Close() + + if err = png.Encode(fw, img); err != nil { + return fmt.Errorf("Encode: %v", err) + } + + log.Info("New random avatar created: %d", u.ID) + return nil +} + +// RelAvatarLink returns relative avatar link to the site domain, +// which includes app sub-url as prefix. However, it is possible +// to return full URL if user enables Gravatar-like service. +func (u *User) RelAvatarLink() string { + defaultImgUrl := setting.AppSubURL + "/img/avatar_default.png" + if u.ID == -1 { + return defaultImgUrl + } + + switch { + case u.UseCustomAvatar: + if !com.IsExist(u.CustomAvatarPath()) { + return defaultImgUrl + } + return fmt.Sprintf("%s/%s/%d", setting.AppSubURL, USER_AVATAR_URL_PREFIX, u.ID) + case setting.DisableGravatar, setting.OfflineMode: + if !com.IsExist(u.CustomAvatarPath()) { + if err := u.GenerateRandomAvatar(); err != nil { + log.Error(3, "GenerateRandomAvatar: %v", err) + } + } + + return fmt.Sprintf("%s/%s/%d", setting.AppSubURL, USER_AVATAR_URL_PREFIX, u.ID) + } + return tool.AvatarLink(u.AvatarEmail) +} + +// AvatarLink returns user avatar absolute link. +func (u *User) AvatarLink() string { + link := u.RelAvatarLink() + if link[0] == '/' && link[1] != '/' { + return setting.AppURL + strings.TrimPrefix(link, setting.AppSubURL)[1:] + } + return link +} + +// User.GetFollwoers returns range of user's followers. +func (u *User) GetFollowers(page int) ([]*User, error) { + users := make([]*User, 0, ItemsPerPage) + sess := x.Limit(ItemsPerPage, (page-1)*ItemsPerPage).Where("follow.follow_id=?", u.ID) + if setting.UsePostgreSQL { + sess = sess.Join("LEFT", "follow", `"user".id=follow.user_id`) + } else { + sess = sess.Join("LEFT", "follow", "user.id=follow.user_id") + } + return users, sess.Find(&users) +} + +func (u *User) IsFollowing(followID int64) bool { + return IsFollowing(u.ID, followID) +} + +// GetFollowing returns range of user's following. +func (u *User) GetFollowing(page int) ([]*User, error) { + users := make([]*User, 0, ItemsPerPage) + sess := x.Limit(ItemsPerPage, (page-1)*ItemsPerPage).Where("follow.user_id=?", u.ID) + if setting.UsePostgreSQL { + sess = sess.Join("LEFT", "follow", `"user".id=follow.follow_id`) + } else { + sess = sess.Join("LEFT", "follow", "user.id=follow.follow_id") + } + return users, sess.Find(&users) +} + +// NewGitSig generates and returns the signature of given user. +func (u *User) NewGitSig() *git.Signature { + return &git.Signature{ + Name: u.DisplayName(), + Email: u.Email, + When: time.Now(), + } +} + +// EncodePasswd encodes password to safe format. +func (u *User) EncodePasswd() { + newPasswd := pbkdf2.Key([]byte(u.Passwd), []byte(u.Salt), 10000, 50, sha256.New) + u.Passwd = fmt.Sprintf("%x", newPasswd) +} + +// ValidatePassword checks if given password matches the one belongs to the user. +func (u *User) ValidatePassword(passwd string) bool { + newUser := &User{Passwd: passwd, Salt: u.Salt} + newUser.EncodePasswd() + return subtle.ConstantTimeCompare([]byte(u.Passwd), []byte(newUser.Passwd)) == 1 +} + +// UploadAvatar saves custom avatar for user. +// FIXME: split uploads to different subdirs in case we have massive number of users. +func (u *User) UploadAvatar(data []byte) error { + img, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + return fmt.Errorf("decode image: %v", err) + } + + os.MkdirAll(setting.AvatarUploadPath, os.ModePerm) + fw, err := os.Create(u.CustomAvatarPath()) + if err != nil { + return fmt.Errorf("create custom avatar directory: %v", err) + } + defer fw.Close() + + m := resize.Resize(avatar.AVATAR_SIZE, avatar.AVATAR_SIZE, img, resize.NearestNeighbor) + if err = png.Encode(fw, m); err != nil { + return fmt.Errorf("encode image: %v", err) + } + + return nil +} + +// DeleteAvatar deletes the user's custom avatar. +func (u *User) DeleteAvatar() error { + log.Trace("DeleteAvatar [%d]: %s", u.ID, u.CustomAvatarPath()) + if err := os.Remove(u.CustomAvatarPath()); err != nil { + return err + } + + u.UseCustomAvatar = false + return UpdateUser(u) +} + +// IsAdminOfRepo returns true if user has admin or higher access of repository. +func (u *User) IsAdminOfRepo(repo *Repository) bool { + has, err := HasAccess(u.ID, repo, ACCESS_MODE_ADMIN) + if err != nil { + log.Error(2, "HasAccess: %v", err) + } + return has +} + +// IsWriterOfRepo returns true if user has write access to given repository. +func (u *User) IsWriterOfRepo(repo *Repository) bool { + has, err := HasAccess(u.ID, repo, ACCESS_MODE_WRITE) + if err != nil { + log.Error(2, "HasAccess: %v", err) + } + return has +} + +// IsOrganization returns true if user is actually a organization. +func (u *User) IsOrganization() bool { + return u.Type == USER_TYPE_ORGANIZATION +} + +// IsUserOrgOwner returns true if user is in the owner team of given organization. +func (u *User) IsUserOrgOwner(orgId int64) bool { + return IsOrganizationOwner(orgId, u.ID) +} + +// IsPublicMember returns true if user public his/her membership in give organization. +func (u *User) IsPublicMember(orgId int64) bool { + return IsPublicMembership(orgId, u.ID) +} + +// IsEnabledTwoFactor returns true if user has enabled two-factor authentication. +func (u *User) IsEnabledTwoFactor() bool { + return IsUserEnabledTwoFactor(u.ID) +} + +func (u *User) getOrganizationCount(e Engine) (int64, error) { + return e.Where("uid=?", u.ID).Count(new(OrgUser)) +} + +// GetOrganizationCount returns count of membership of organization of user. +func (u *User) GetOrganizationCount() (int64, error) { + return u.getOrganizationCount(x) +} + +// GetRepositories returns repositories that user owns, including private repositories. +func (u *User) GetRepositories(page, pageSize int) (err error) { + u.Repos, err = GetUserRepositories(&UserRepoOptions{ + UserID: u.ID, + Private: true, + Page: page, + PageSize: pageSize, + }) + return err +} + +// GetRepositories returns mirror repositories that user owns, including private repositories. +func (u *User) GetMirrorRepositories() ([]*Repository, error) { + return GetUserMirrorRepositories(u.ID) +} + +// GetOwnedOrganizations returns all organizations that user owns. +func (u *User) GetOwnedOrganizations() (err error) { + u.OwnedOrgs, err = GetOwnedOrgsByUserID(u.ID) + return err +} + +// GetOrganizations returns all organizations that user belongs to. +func (u *User) GetOrganizations(showPrivate bool) error { + orgIDs, err := GetOrgIDsByUserID(u.ID, showPrivate) + if err != nil { + return fmt.Errorf("GetOrgIDsByUserID: %v", err) + } + if len(orgIDs) == 0 { + return nil + } + + u.Orgs = make([]*User, 0, len(orgIDs)) + if err = x.Where("type = ?", USER_TYPE_ORGANIZATION).In("id", orgIDs).Find(&u.Orgs); err != nil { + return err + } + return nil +} + +// DisplayName returns full name if it's not empty, +// returns username otherwise. +func (u *User) DisplayName() string { + if len(u.FullName) > 0 { + return u.FullName + } + return u.Name +} + +func (u *User) ShortName(length int) string { + return tool.EllipsisString(u.Name, length) +} + +// IsMailable checks if a user is elegible +// to receive emails. +func (u *User) IsMailable() bool { + return u.IsActive +} + +// IsUserExist checks if given user name exist, +// the user name should be noncased unique. +// If uid is presented, then check will rule out that one, +// it is used when update a user name in settings page. +func IsUserExist(uid int64, name string) (bool, error) { + if len(name) == 0 { + return false, nil + } + return x.Where("id != ?", uid).Get(&User{LowerName: strings.ToLower(name)}) +} + +// GetUserSalt returns a ramdom user salt token. +func GetUserSalt() (string, error) { + return tool.RandomString(10) +} + +// NewGhostUser creates and returns a fake user for someone who has deleted his/her account. +func NewGhostUser() *User { + return &User{ + ID: -1, + Name: "Ghost", + LowerName: "ghost", + } +} + +var ( + reservedUsernames = []string{"explore", "create", "assets", "css", "img", "js", "less", "plugins", "debug", "raw", "install", "api", "avatar", "user", "org", "help", "stars", "issues", "pulls", "commits", "repo", "template", "admin", "new", ".", ".."} + reservedUserPatterns = []string{"*.keys"} +) + +// isUsableName checks if name is reserved or pattern of name is not allowed +// based on given reserved names and patterns. +// Names are exact match, patterns can be prefix or suffix match with placeholder '*'. +func isUsableName(names, patterns []string, name string) error { + name = strings.TrimSpace(strings.ToLower(name)) + if utf8.RuneCountInString(name) == 0 { + return errors.EmptyName{} + } + + for i := range names { + if name == names[i] { + return ErrNameReserved{name} + } + } + + for _, pat := range patterns { + if pat[0] == '*' && strings.HasSuffix(name, pat[1:]) || + (pat[len(pat)-1] == '*' && strings.HasPrefix(name, pat[:len(pat)-1])) { + return ErrNamePatternNotAllowed{pat} + } + } + + return nil +} + +func IsUsableUsername(name string) error { + return isUsableName(reservedUsernames, reservedUserPatterns, name) +} + +// CreateUser creates record of a new user. +func CreateUser(u *User) (err error) { + if err = IsUsableUsername(u.Name); err != nil { + return err + } + + isExist, err := IsUserExist(0, u.Name) + if err != nil { + return err + } else if isExist { + return ErrUserAlreadyExist{u.Name} + } + + u.Email = strings.ToLower(u.Email) + isExist, err = IsEmailUsed(u.Email) + if err != nil { + return err + } else if isExist { + return ErrEmailAlreadyUsed{u.Email} + } + + u.LowerName = strings.ToLower(u.Name) + u.AvatarEmail = u.Email + u.Avatar = tool.HashEmail(u.AvatarEmail) + if u.Rands, err = GetUserSalt(); err != nil { + return err + } + if u.Salt, err = GetUserSalt(); err != nil { + return err + } + u.EncodePasswd() + u.MaxRepoCreation = -1 + + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return err + } + + if _, err = sess.Insert(u); err != nil { + return err + } else if err = os.MkdirAll(UserPath(u.Name), os.ModePerm); err != nil { + return err + } + + return sess.Commit() +} + +func countUsers(e Engine) int64 { + count, _ := e.Where("type=0").Count(new(User)) + return count +} + +// CountUsers returns number of users. +func CountUsers() int64 { + return countUsers(x) +} + +// Users returns number of users in given page. +func Users(page, pageSize int) ([]*User, error) { + users := make([]*User, 0, pageSize) + return users, x.Limit(pageSize, (page-1)*pageSize).Where("type=0").Asc("id").Find(&users) +} + +// parseUserFromCode returns user by username encoded in code. +// It returns nil if code or username is invalid. +func parseUserFromCode(code string) (user *User) { + if len(code) <= tool.TIME_LIMIT_CODE_LENGTH { + return nil + } + + // Use tail hex username to query user + hexStr := code[tool.TIME_LIMIT_CODE_LENGTH:] + if b, err := hex.DecodeString(hexStr); err == nil { + if user, err = GetUserByName(string(b)); user != nil { + return user + } else if !errors.IsUserNotExist(err) { + log.Error(2, "GetUserByName: %v", err) + } + } + + return nil +} + +// verify active code when active account +func VerifyUserActiveCode(code string) (user *User) { + minutes := setting.Service.ActiveCodeLives + + if user = parseUserFromCode(code); user != nil { + // time limit code + prefix := code[:tool.TIME_LIMIT_CODE_LENGTH] + data := com.ToStr(user.ID) + user.Email + user.LowerName + user.Passwd + user.Rands + + if tool.VerifyTimeLimitCode(data, minutes, prefix) { + return user + } + } + return nil +} + +// verify active code when active account +func VerifyActiveEmailCode(code, email string) *EmailAddress { + minutes := setting.Service.ActiveCodeLives + + if user := parseUserFromCode(code); user != nil { + // time limit code + prefix := code[:tool.TIME_LIMIT_CODE_LENGTH] + data := com.ToStr(user.ID) + email + user.LowerName + user.Passwd + user.Rands + + if tool.VerifyTimeLimitCode(data, minutes, prefix) { + emailAddress := &EmailAddress{Email: email} + if has, _ := x.Get(emailAddress); has { + return emailAddress + } + } + } + return nil +} + +// ChangeUserName changes all corresponding setting from old user name to new one. +func ChangeUserName(u *User, newUserName string) (err error) { + if err = IsUsableUsername(newUserName); err != nil { + return err + } + + isExist, err := IsUserExist(0, newUserName) + if err != nil { + return err + } else if isExist { + return ErrUserAlreadyExist{newUserName} + } + + if err = ChangeUsernameInPullRequests(u.Name, newUserName); err != nil { + return fmt.Errorf("ChangeUsernameInPullRequests: %v", err) + } + + // Delete all local copies of repository wiki that user owns. + if err = x.Where("owner_id=?", u.ID).Iterate(new(Repository), func(idx int, bean interface{}) error { + repo := bean.(*Repository) + RemoveAllWithNotice("Delete repository wiki local copy", repo.LocalWikiPath()) + return nil + }); err != nil { + return fmt.Errorf("Delete repository wiki local copy: %v", err) + } + + // Rename or create user base directory + baseDir := UserPath(u.Name) + newBaseDir := UserPath(newUserName) + if com.IsExist(baseDir) { + return os.Rename(baseDir, newBaseDir) + } + return os.MkdirAll(newBaseDir, os.ModePerm) +} + +func updateUser(e Engine, u *User) error { + // Organization does not need email + if !u.IsOrganization() { + u.Email = strings.ToLower(u.Email) + has, err := e.Where("id!=?", u.ID).And("type=?", u.Type).And("email=?", u.Email).Get(new(User)) + if err != nil { + return err + } else if has { + return ErrEmailAlreadyUsed{u.Email} + } + + if len(u.AvatarEmail) == 0 { + u.AvatarEmail = u.Email + } + u.Avatar = tool.HashEmail(u.AvatarEmail) + } + + u.LowerName = strings.ToLower(u.Name) + u.Location = tool.TruncateString(u.Location, 255) + u.Website = tool.TruncateString(u.Website, 255) + u.Description = tool.TruncateString(u.Description, 255) + + _, err := e.ID(u.ID).AllCols().Update(u) + return err +} + +// UpdateUser updates user's information. +func UpdateUser(u *User) error { + return updateUser(x, u) +} + +// deleteBeans deletes all given beans, beans should contain delete conditions. +func deleteBeans(e Engine, beans ...interface{}) (err error) { + for i := range beans { + if _, err = e.Delete(beans[i]); err != nil { + return err + } + } + return nil +} + +// FIXME: need some kind of mechanism to record failure. HINT: system notice +func deleteUser(e *xorm.Session, u *User) error { + // Note: A user owns any repository or belongs to any organization + // cannot perform delete operation. + + // Check ownership of repository. + count, err := getRepositoryCount(e, u) + if err != nil { + return fmt.Errorf("GetRepositoryCount: %v", err) + } else if count > 0 { + return ErrUserOwnRepos{UID: u.ID} + } + + // Check membership of organization. + count, err = u.getOrganizationCount(e) + if err != nil { + return fmt.Errorf("GetOrganizationCount: %v", err) + } else if count > 0 { + return ErrUserHasOrgs{UID: u.ID} + } + + // ***** START: Watch ***** + watches := make([]*Watch, 0, 10) + if err = e.Find(&watches, &Watch{UserID: u.ID}); err != nil { + return fmt.Errorf("get all watches: %v", err) + } + for i := range watches { + if _, err = e.Exec("UPDATE `repository` SET num_watches=num_watches-1 WHERE id=?", watches[i].RepoID); err != nil { + return fmt.Errorf("decrease repository watch number[%d]: %v", watches[i].RepoID, err) + } + } + // ***** END: Watch ***** + + // ***** START: Star ***** + stars := make([]*Star, 0, 10) + if err = e.Find(&stars, &Star{UID: u.ID}); err != nil { + return fmt.Errorf("get all stars: %v", err) + } + for i := range stars { + if _, err = e.Exec("UPDATE `repository` SET num_stars=num_stars-1 WHERE id=?", stars[i].RepoID); err != nil { + return fmt.Errorf("decrease repository star number[%d]: %v", stars[i].RepoID, err) + } + } + // ***** END: Star ***** + + // ***** START: Follow ***** + followers := make([]*Follow, 0, 10) + if err = e.Find(&followers, &Follow{UserID: u.ID}); err != nil { + return fmt.Errorf("get all followers: %v", err) + } + for i := range followers { + if _, err = e.Exec("UPDATE `user` SET num_followers=num_followers-1 WHERE id=?", followers[i].UserID); err != nil { + return fmt.Errorf("decrease user follower number[%d]: %v", followers[i].UserID, err) + } + } + // ***** END: Follow ***** + + if err = deleteBeans(e, + &AccessToken{UID: u.ID}, + &Collaboration{UserID: u.ID}, + &Access{UserID: u.ID}, + &Watch{UserID: u.ID}, + &Star{UID: u.ID}, + &Follow{FollowID: u.ID}, + &Action{UserID: u.ID}, + &IssueUser{UID: u.ID}, + &EmailAddress{UID: u.ID}, + ); err != nil { + return fmt.Errorf("deleteBeans: %v", err) + } + + // ***** START: PublicKey ***** + keys := make([]*PublicKey, 0, 10) + if err = e.Find(&keys, &PublicKey{OwnerID: u.ID}); err != nil { + return fmt.Errorf("get all public keys: %v", err) + } + + keyIDs := make([]int64, len(keys)) + for i := range keys { + keyIDs[i] = keys[i].ID + } + if err = deletePublicKeys(e, keyIDs...); err != nil { + return fmt.Errorf("deletePublicKeys: %v", err) + } + // ***** END: PublicKey ***** + + // Clear assignee. + if _, err = e.Exec("UPDATE `issue` SET assignee_id=0 WHERE assignee_id=?", u.ID); err != nil { + return fmt.Errorf("clear assignee: %v", err) + } + + if _, err = e.Id(u.ID).Delete(new(User)); err != nil { + return fmt.Errorf("Delete: %v", err) + } + + // FIXME: system notice + // Note: There are something just cannot be roll back, + // so just keep error logs of those operations. + + os.RemoveAll(UserPath(u.Name)) + os.Remove(u.CustomAvatarPath()) + + return nil +} + +// DeleteUser completely and permanently deletes everything of a user, +// but issues/comments/pulls will be kept and shown as someone has been deleted. +func DeleteUser(u *User) (err error) { + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return err + } + + if err = deleteUser(sess, u); err != nil { + // Note: don't wrapper error here. + return err + } + + if err = sess.Commit(); err != nil { + return err + } + + return RewriteAuthorizedKeys() +} + +// DeleteInactivateUsers deletes all inactivate users and email addresses. +func DeleteInactivateUsers() (err error) { + users := make([]*User, 0, 10) + if err = x.Where("is_active = ?", false).Find(&users); err != nil { + return fmt.Errorf("get all inactive users: %v", err) + } + // FIXME: should only update authorized_keys file once after all deletions. + for _, u := range users { + if err = DeleteUser(u); err != nil { + // Ignore users that were set inactive by admin. + if IsErrUserOwnRepos(err) || IsErrUserHasOrgs(err) { + continue + } + return err + } + } + + _, err = x.Where("is_activated = ?", false).Delete(new(EmailAddress)) + return err +} + +// UserPath returns the path absolute path of user repositories. +func UserPath(userName string) string { + return filepath.Join(setting.RepoRootPath, strings.ToLower(userName)) +} + +func GetUserByKeyID(keyID int64) (*User, error) { + user := new(User) + has, err := x.SQL("SELECT a.* FROM `user` AS a, public_key AS b WHERE a.id = b.owner_id AND b.id=?", keyID).Get(user) + if err != nil { + return nil, err + } else if !has { + return nil, errors.UserNotKeyOwner{keyID} + } + return user, nil +} + +func getUserByID(e Engine, id int64) (*User, error) { + u := new(User) + has, err := e.ID(id).Get(u) + if err != nil { + return nil, err + } else if !has { + return nil, errors.UserNotExist{id, ""} + } + return u, nil +} + +// GetUserByID returns the user object by given ID if exists. +func GetUserByID(id int64) (*User, error) { + return getUserByID(x, id) +} + +// GetAssigneeByID returns the user with write access of repository by given ID. +func GetAssigneeByID(repo *Repository, userID int64) (*User, error) { + has, err := HasAccess(userID, repo, ACCESS_MODE_READ) + if err != nil { + return nil, err + } else if !has { + return nil, errors.UserNotExist{userID, ""} + } + return GetUserByID(userID) +} + +// GetUserByName returns a user by given name. +func GetUserByName(name string) (*User, error) { + if len(name) == 0 { + return nil, errors.UserNotExist{0, name} + } + u := &User{LowerName: strings.ToLower(name)} + has, err := x.Get(u) + if err != nil { + return nil, err + } else if !has { + return nil, errors.UserNotExist{0, name} + } + return u, nil +} + +// GetUserEmailsByNames returns a list of e-mails corresponds to names. +func GetUserEmailsByNames(names []string) []string { + mails := make([]string, 0, len(names)) + for _, name := range names { + u, err := GetUserByName(name) + if err != nil { + continue + } + if u.IsMailable() { + mails = append(mails, u.Email) + } + } + return mails +} + +// GetUserIDsByNames returns a slice of ids corresponds to names. +func GetUserIDsByNames(names []string) []int64 { + ids := make([]int64, 0, len(names)) + for _, name := range names { + u, err := GetUserByName(name) + if err != nil { + continue + } + ids = append(ids, u.ID) + } + return ids +} + +// UserCommit represents a commit with validation of user. +type UserCommit struct { + User *User + *git.Commit +} + +// ValidateCommitWithEmail chceck if author's e-mail of commit is corresponsind to a user. +func ValidateCommitWithEmail(c *git.Commit) *User { + u, err := GetUserByEmail(c.Author.Email) + if err != nil { + return nil + } + return u +} + +// ValidateCommitsWithEmails checks if authors' e-mails of commits are corresponding to users. +func ValidateCommitsWithEmails(oldCommits *list.List) *list.List { + var ( + u *User + emails = map[string]*User{} + newCommits = list.New() + e = oldCommits.Front() + ) + for e != nil { + c := e.Value.(*git.Commit) + + if v, ok := emails[c.Author.Email]; !ok { + u, _ = GetUserByEmail(c.Author.Email) + emails[c.Author.Email] = u + } else { + u = v + } + + newCommits.PushBack(UserCommit{ + User: u, + Commit: c, + }) + e = e.Next() + } + return newCommits +} + +// GetUserByEmail returns the user object by given e-mail if exists. +func GetUserByEmail(email string) (*User, error) { + if len(email) == 0 { + return nil, errors.UserNotExist{0, "email"} + } + + email = strings.ToLower(email) + // First try to find the user by primary email + user := &User{Email: email} + has, err := x.Get(user) + if err != nil { + return nil, err + } + if has { + return user, nil + } + + // Otherwise, check in alternative list for activated email addresses + emailAddress := &EmailAddress{Email: email, IsActivated: true} + has, err = x.Get(emailAddress) + if err != nil { + return nil, err + } + if has { + return GetUserByID(emailAddress.UID) + } + + return nil, errors.UserNotExist{0, email} +} + +type SearchUserOptions struct { + Keyword string + Type UserType + OrderBy string + Page int + PageSize int // Can be smaller than or equal to setting.UI.ExplorePagingNum +} + +// SearchUserByName takes keyword and part of user name to search, +// it returns results in given range and number of total results. +func SearchUserByName(opts *SearchUserOptions) (users []*User, _ int64, _ error) { + if len(opts.Keyword) == 0 { + return users, 0, nil + } + opts.Keyword = strings.ToLower(opts.Keyword) + + if opts.PageSize <= 0 || opts.PageSize > setting.UI.ExplorePagingNum { + opts.PageSize = setting.UI.ExplorePagingNum + } + if opts.Page <= 0 { + opts.Page = 1 + } + + searchQuery := "%" + opts.Keyword + "%" + users = make([]*User, 0, opts.PageSize) + // Append conditions + sess := x.Where("LOWER(lower_name) LIKE ?", searchQuery). + Or("LOWER(full_name) LIKE ?", searchQuery). + And("type = ?", opts.Type) + + var countSess xorm.Session + countSess = *sess + count, err := countSess.Count(new(User)) + if err != nil { + return nil, 0, fmt.Errorf("Count: %v", err) + } + + if len(opts.OrderBy) > 0 { + sess.OrderBy(opts.OrderBy) + } + return users, count, sess.Limit(opts.PageSize, (opts.Page-1)*opts.PageSize).Find(&users) +} + +// ___________ .__ .__ +// \_ _____/___ | | | | ______ _ __ +// | __)/ _ \| | | | / _ \ \/ \/ / +// | \( <_> ) |_| |_( <_> ) / +// \___ / \____/|____/____/\____/ \/\_/ +// \/ + +// Follow represents relations of user and his/her followers. +type Follow struct { + ID int64 + UserID int64 `xorm:"UNIQUE(follow)"` + FollowID int64 `xorm:"UNIQUE(follow)"` +} + +func IsFollowing(userID, followID int64) bool { + has, _ := x.Get(&Follow{UserID: userID, FollowID: followID}) + return has +} + +// FollowUser marks someone be another's follower. +func FollowUser(userID, followID int64) (err error) { + if userID == followID || IsFollowing(userID, followID) { + return nil + } + + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return err + } + + if _, err = sess.Insert(&Follow{UserID: userID, FollowID: followID}); err != nil { + return err + } + + if _, err = sess.Exec("UPDATE `user` SET num_followers = num_followers + 1 WHERE id = ?", followID); err != nil { + return err + } + + if _, err = sess.Exec("UPDATE `user` SET num_following = num_following + 1 WHERE id = ?", userID); err != nil { + return err + } + return sess.Commit() +} + +// UnfollowUser unmarks someone be another's follower. +func UnfollowUser(userID, followID int64) (err error) { + if userID == followID || !IsFollowing(userID, followID) { + return nil + } + + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return err + } + + if _, err = sess.Delete(&Follow{UserID: userID, FollowID: followID}); err != nil { + return err + } + + if _, err = sess.Exec("UPDATE `user` SET num_followers = num_followers - 1 WHERE id = ?", followID); err != nil { + return err + } + + if _, err = sess.Exec("UPDATE `user` SET num_following = num_following - 1 WHERE id = ?", userID); err != nil { + return err + } + return sess.Commit() +} diff --git a/internal/db/user_cache.go b/internal/db/user_cache.go new file mode 100644 index 00000000..314ea3b1 --- /dev/null +++ b/internal/db/user_cache.go @@ -0,0 +1,16 @@ +// Copyright 2018 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package db + +// MailResendCacheKey returns key used for cache mail resend. +func (u *User) MailResendCacheKey() string { + return "MailResend_" + u.IDStr() +} + +// TwoFactorCacheKey returns key used for cache two factor passcode. +// e.g. TwoFactor_1_012664 +func (u *User) TwoFactorCacheKey(passcode string) string { + return "TwoFactor_" + u.IDStr() + "_" + passcode +} diff --git a/internal/db/user_mail.go b/internal/db/user_mail.go new file mode 100644 index 00000000..5304f13b --- /dev/null +++ b/internal/db/user_mail.go @@ -0,0 +1,210 @@ +// Copyright 2016 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package db + +import ( + "fmt" + "strings" + + "gogs.io/gogs/internal/db/errors" +) + +// EmailAdresses is the list of all email addresses of a user. Can contain the +// primary email address, but is not obligatory. +type EmailAddress struct { + ID int64 + UID int64 `xorm:"INDEX NOT NULL"` + Email string `xorm:"UNIQUE NOT NULL"` + IsActivated bool + IsPrimary bool `xorm:"-" json:"-"` +} + +// GetEmailAddresses returns all email addresses belongs to given user. +func GetEmailAddresses(uid int64) ([]*EmailAddress, error) { + emails := make([]*EmailAddress, 0, 5) + if err := x.Where("uid=?", uid).Find(&emails); err != nil { + return nil, err + } + + u, err := GetUserByID(uid) + if err != nil { + return nil, err + } + + isPrimaryFound := false + for _, email := range emails { + if email.Email == u.Email { + isPrimaryFound = true + email.IsPrimary = true + } else { + email.IsPrimary = false + } + } + + // We alway want the primary email address displayed, even if it's not in + // the emailaddress table (yet). + if !isPrimaryFound { + emails = append(emails, &EmailAddress{ + Email: u.Email, + IsActivated: true, + IsPrimary: true, + }) + } + return emails, nil +} + +func isEmailUsed(e Engine, email string) (bool, error) { + if len(email) == 0 { + return true, nil + } + + has, err := e.Get(&EmailAddress{Email: email}) + if err != nil { + return false, err + } else if has { + return true, nil + } + + // We need to check primary email of users as well. + return e.Where("type=?", USER_TYPE_INDIVIDUAL).And("email=?", email).Get(new(User)) +} + +// IsEmailUsed returns true if the email has been used. +func IsEmailUsed(email string) (bool, error) { + return isEmailUsed(x, email) +} + +func addEmailAddress(e Engine, email *EmailAddress) error { + email.Email = strings.ToLower(strings.TrimSpace(email.Email)) + used, err := isEmailUsed(e, email.Email) + if err != nil { + return err + } else if used { + return ErrEmailAlreadyUsed{email.Email} + } + + _, err = e.Insert(email) + return err +} + +func AddEmailAddress(email *EmailAddress) error { + return addEmailAddress(x, email) +} + +func AddEmailAddresses(emails []*EmailAddress) error { + if len(emails) == 0 { + return nil + } + + // Check if any of them has been used + for i := range emails { + emails[i].Email = strings.ToLower(strings.TrimSpace(emails[i].Email)) + used, err := IsEmailUsed(emails[i].Email) + if err != nil { + return err + } else if used { + return ErrEmailAlreadyUsed{emails[i].Email} + } + } + + if _, err := x.Insert(emails); err != nil { + return fmt.Errorf("Insert: %v", err) + } + + return nil +} + +func (email *EmailAddress) Activate() error { + user, err := GetUserByID(email.UID) + if err != nil { + return err + } + if user.Rands, err = GetUserSalt(); err != nil { + return err + } + + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return err + } + + email.IsActivated = true + if _, err := sess.ID(email.ID).AllCols().Update(email); err != nil { + return err + } else if err = updateUser(sess, user); err != nil { + return err + } + + return sess.Commit() +} + +func DeleteEmailAddress(email *EmailAddress) (err error) { + if email.ID > 0 { + _, err = x.Id(email.ID).Delete(new(EmailAddress)) + } else { + _, err = x.Where("email=?", email.Email).Delete(new(EmailAddress)) + } + return err +} + +func DeleteEmailAddresses(emails []*EmailAddress) (err error) { + for i := range emails { + if err = DeleteEmailAddress(emails[i]); err != nil { + return err + } + } + + return nil +} + +func MakeEmailPrimary(email *EmailAddress) error { + has, err := x.Get(email) + if err != nil { + return err + } else if !has { + return errors.EmailNotFound{email.Email} + } + + if !email.IsActivated { + return errors.EmailNotVerified{email.Email} + } + + user := &User{ID: email.UID} + has, err = x.Get(user) + if err != nil { + return err + } else if !has { + return errors.UserNotExist{email.UID, ""} + } + + // Make sure the former primary email doesn't disappear. + formerPrimaryEmail := &EmailAddress{Email: user.Email} + has, err = x.Get(formerPrimaryEmail) + if err != nil { + return err + } + + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return err + } + + if !has { + formerPrimaryEmail.UID = user.ID + formerPrimaryEmail.IsActivated = user.IsActive + if _, err = sess.Insert(formerPrimaryEmail); err != nil { + return err + } + } + + user.Email = email.Email + if _, err = sess.ID(user.ID).AllCols().Update(user); err != nil { + return err + } + + return sess.Commit() +} diff --git a/internal/db/webhook.go b/internal/db/webhook.go new file mode 100644 index 00000000..750cc613 --- /dev/null +++ b/internal/db/webhook.go @@ -0,0 +1,771 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package db + +import ( + "crypto/hmac" + "crypto/sha256" + "crypto/tls" + "encoding/hex" + "fmt" + "io/ioutil" + "strings" + "time" + + "github.com/json-iterator/go" + gouuid "github.com/satori/go.uuid" + log "gopkg.in/clog.v1" + "xorm.io/xorm" + + api "github.com/gogs/go-gogs-client" + + "gogs.io/gogs/internal/db/errors" + "gogs.io/gogs/internal/httplib" + "gogs.io/gogs/internal/setting" + "gogs.io/gogs/internal/sync" +) + +var HookQueue = sync.NewUniqueQueue(setting.Webhook.QueueLength) + +type HookContentType int + +const ( + JSON HookContentType = iota + 1 + FORM +) + +var hookContentTypes = map[string]HookContentType{ + "json": JSON, + "form": FORM, +} + +// ToHookContentType returns HookContentType by given name. +func ToHookContentType(name string) HookContentType { + return hookContentTypes[name] +} + +func (t HookContentType) Name() string { + switch t { + case JSON: + return "json" + case FORM: + return "form" + } + return "" +} + +// IsValidHookContentType returns true if given name is a valid hook content type. +func IsValidHookContentType(name string) bool { + _, ok := hookContentTypes[name] + return ok +} + +type HookEvents struct { + Create bool `json:"create"` + Delete bool `json:"delete"` + Fork bool `json:"fork"` + Push bool `json:"push"` + Issues bool `json:"issues"` + PullRequest bool `json:"pull_request"` + IssueComment bool `json:"issue_comment"` + Release bool `json:"release"` +} + +// HookEvent represents events that will delivery hook. +type HookEvent struct { + PushOnly bool `json:"push_only"` + SendEverything bool `json:"send_everything"` + ChooseEvents bool `json:"choose_events"` + + HookEvents `json:"events"` +} + +type HookStatus int + +const ( + HOOK_STATUS_NONE = iota + HOOK_STATUS_SUCCEED + HOOK_STATUS_FAILED +) + +// Webhook represents a web hook object. +type Webhook struct { + ID int64 + RepoID int64 + OrgID int64 + URL string `xorm:"url TEXT"` + ContentType HookContentType + Secret string `xorm:"TEXT"` + Events string `xorm:"TEXT"` + *HookEvent `xorm:"-"` // LEGACY [1.0]: Cannot ignore JSON here, it breaks old backup archive + IsSSL bool `xorm:"is_ssl"` + IsActive bool + HookTaskType HookTaskType + Meta string `xorm:"TEXT"` // store hook-specific attributes + LastStatus HookStatus // Last delivery status + + Created time.Time `xorm:"-" json:"-"` + CreatedUnix int64 + Updated time.Time `xorm:"-" json:"-"` + UpdatedUnix int64 +} + +func (w *Webhook) BeforeInsert() { + w.CreatedUnix = time.Now().Unix() + w.UpdatedUnix = w.CreatedUnix +} + +func (w *Webhook) BeforeUpdate() { + w.UpdatedUnix = time.Now().Unix() +} + +func (w *Webhook) AfterSet(colName string, _ xorm.Cell) { + var err error + switch colName { + case "events": + w.HookEvent = &HookEvent{} + if err = jsoniter.Unmarshal([]byte(w.Events), w.HookEvent); err != nil { + log.Error(3, "Unmarshal [%d]: %v", w.ID, err) + } + case "created_unix": + w.Created = time.Unix(w.CreatedUnix, 0).Local() + case "updated_unix": + w.Updated = time.Unix(w.UpdatedUnix, 0).Local() + } +} + +func (w *Webhook) GetSlackHook() *SlackMeta { + s := &SlackMeta{} + if err := jsoniter.Unmarshal([]byte(w.Meta), s); err != nil { + log.Error(2, "GetSlackHook [%d]: %v", w.ID, err) + } + return s +} + +// History returns history of webhook by given conditions. +func (w *Webhook) History(page int) ([]*HookTask, error) { + return HookTasks(w.ID, page) +} + +// UpdateEvent handles conversion from HookEvent to Events. +func (w *Webhook) UpdateEvent() error { + data, err := jsoniter.Marshal(w.HookEvent) + w.Events = string(data) + return err +} + +// HasCreateEvent returns true if hook enabled create event. +func (w *Webhook) HasCreateEvent() bool { + return w.SendEverything || + (w.ChooseEvents && w.HookEvents.Create) +} + +// HasDeleteEvent returns true if hook enabled delete event. +func (w *Webhook) HasDeleteEvent() bool { + return w.SendEverything || + (w.ChooseEvents && w.HookEvents.Delete) +} + +// HasForkEvent returns true if hook enabled fork event. +func (w *Webhook) HasForkEvent() bool { + return w.SendEverything || + (w.ChooseEvents && w.HookEvents.Fork) +} + +// HasPushEvent returns true if hook enabled push event. +func (w *Webhook) HasPushEvent() bool { + return w.PushOnly || w.SendEverything || + (w.ChooseEvents && w.HookEvents.Push) +} + +// HasIssuesEvent returns true if hook enabled issues event. +func (w *Webhook) HasIssuesEvent() bool { + return w.SendEverything || + (w.ChooseEvents && w.HookEvents.Issues) +} + +// HasPullRequestEvent returns true if hook enabled pull request event. +func (w *Webhook) HasPullRequestEvent() bool { + return w.SendEverything || + (w.ChooseEvents && w.HookEvents.PullRequest) +} + +// HasIssueCommentEvent returns true if hook enabled issue comment event. +func (w *Webhook) HasIssueCommentEvent() bool { + return w.SendEverything || + (w.ChooseEvents && w.HookEvents.IssueComment) +} + +// HasReleaseEvent returns true if hook enabled release event. +func (w *Webhook) HasReleaseEvent() bool { + return w.SendEverything || + (w.ChooseEvents && w.HookEvents.Release) +} + +type eventChecker struct { + checker func() bool + typ HookEventType +} + +func (w *Webhook) EventsArray() []string { + events := make([]string, 0, 8) + eventCheckers := []eventChecker{ + {w.HasCreateEvent, HOOK_EVENT_CREATE}, + {w.HasDeleteEvent, HOOK_EVENT_DELETE}, + {w.HasForkEvent, HOOK_EVENT_FORK}, + {w.HasPushEvent, HOOK_EVENT_PUSH}, + {w.HasIssuesEvent, HOOK_EVENT_ISSUES}, + {w.HasPullRequestEvent, HOOK_EVENT_PULL_REQUEST}, + {w.HasIssueCommentEvent, HOOK_EVENT_ISSUE_COMMENT}, + {w.HasReleaseEvent, HOOK_EVENT_RELEASE}, + } + for _, c := range eventCheckers { + if c.checker() { + events = append(events, string(c.typ)) + } + } + return events +} + +// CreateWebhook creates a new web hook. +func CreateWebhook(w *Webhook) error { + _, err := x.Insert(w) + return err +} + +// getWebhook uses argument bean as query condition, +// ID must be specified and do not assign unnecessary fields. +func getWebhook(bean *Webhook) (*Webhook, error) { + has, err := x.Get(bean) + if err != nil { + return nil, err + } else if !has { + return nil, errors.WebhookNotExist{bean.ID} + } + return bean, nil +} + +// GetWebhookByID returns webhook by given ID. +// Use this function with caution of accessing unauthorized webhook, +// which means should only be used in non-user interactive functions. +func GetWebhookByID(id int64) (*Webhook, error) { + return getWebhook(&Webhook{ + ID: id, + }) +} + +// GetWebhookOfRepoByID returns webhook of repository by given ID. +func GetWebhookOfRepoByID(repoID, id int64) (*Webhook, error) { + return getWebhook(&Webhook{ + ID: id, + RepoID: repoID, + }) +} + +// GetWebhookByOrgID returns webhook of organization by given ID. +func GetWebhookByOrgID(orgID, id int64) (*Webhook, error) { + return getWebhook(&Webhook{ + ID: id, + OrgID: orgID, + }) +} + +// getActiveWebhooksByRepoID returns all active webhooks of repository. +func getActiveWebhooksByRepoID(e Engine, repoID int64) ([]*Webhook, error) { + webhooks := make([]*Webhook, 0, 5) + return webhooks, e.Where("repo_id = ?", repoID).And("is_active = ?", true).Find(&webhooks) +} + +// GetWebhooksByRepoID returns all webhooks of a repository. +func GetWebhooksByRepoID(repoID int64) ([]*Webhook, error) { + webhooks := make([]*Webhook, 0, 5) + return webhooks, x.Find(&webhooks, &Webhook{RepoID: repoID}) +} + +// UpdateWebhook updates information of webhook. +func UpdateWebhook(w *Webhook) error { + _, err := x.Id(w.ID).AllCols().Update(w) + return err +} + +// deleteWebhook uses argument bean as query condition, +// ID must be specified and do not assign unnecessary fields. +func deleteWebhook(bean *Webhook) (err error) { + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return err + } + + if _, err = sess.Delete(bean); err != nil { + return err + } else if _, err = sess.Delete(&HookTask{HookID: bean.ID}); err != nil { + return err + } + + return sess.Commit() +} + +// DeleteWebhookOfRepoByID deletes webhook of repository by given ID. +func DeleteWebhookOfRepoByID(repoID, id int64) error { + return deleteWebhook(&Webhook{ + ID: id, + RepoID: repoID, + }) +} + +// DeleteWebhookOfOrgByID deletes webhook of organization by given ID. +func DeleteWebhookOfOrgByID(orgID, id int64) error { + return deleteWebhook(&Webhook{ + ID: id, + OrgID: orgID, + }) +} + +// GetWebhooksByOrgID returns all webhooks for an organization. +func GetWebhooksByOrgID(orgID int64) (ws []*Webhook, err error) { + err = x.Find(&ws, &Webhook{OrgID: orgID}) + return ws, err +} + +// getActiveWebhooksByOrgID returns all active webhooks for an organization. +func getActiveWebhooksByOrgID(e Engine, orgID int64) ([]*Webhook, error) { + ws := make([]*Webhook, 0, 3) + return ws, e.Where("org_id=?", orgID).And("is_active=?", true).Find(&ws) +} + +// ___ ___ __ ___________ __ +// / | \ ____ ____ | | _\__ ___/____ _____| | __ +// / ~ \/ _ \ / _ \| |/ / | | \__ \ / ___/ |/ / +// \ Y ( <_> | <_> ) < | | / __ \_\___ \| < +// \___|_ / \____/ \____/|__|_ \ |____| (____ /____ >__|_ \ +// \/ \/ \/ \/ \/ + +type HookTaskType int + +const ( + GOGS HookTaskType = iota + 1 + SLACK + DISCORD + DINGTALK +) + +var hookTaskTypes = map[string]HookTaskType{ + "gogs": GOGS, + "slack": SLACK, + "discord": DISCORD, + "dingtalk": DINGTALK, +} + +// ToHookTaskType returns HookTaskType by given name. +func ToHookTaskType(name string) HookTaskType { + return hookTaskTypes[name] +} + +func (t HookTaskType) Name() string { + switch t { + case GOGS: + return "gogs" + case SLACK: + return "slack" + case DISCORD: + return "discord" + case DINGTALK: + return "dingtalk" + } + return "" +} + +// IsValidHookTaskType returns true if given name is a valid hook task type. +func IsValidHookTaskType(name string) bool { + _, ok := hookTaskTypes[name] + return ok +} + +type HookEventType string + +const ( + HOOK_EVENT_CREATE HookEventType = "create" + HOOK_EVENT_DELETE HookEventType = "delete" + HOOK_EVENT_FORK HookEventType = "fork" + HOOK_EVENT_PUSH HookEventType = "push" + HOOK_EVENT_ISSUES HookEventType = "issues" + HOOK_EVENT_PULL_REQUEST HookEventType = "pull_request" + HOOK_EVENT_ISSUE_COMMENT HookEventType = "issue_comment" + HOOK_EVENT_RELEASE HookEventType = "release" +) + +// HookRequest represents hook task request information. +type HookRequest struct { + Headers map[string]string `json:"headers"` +} + +// HookResponse represents hook task response information. +type HookResponse struct { + Status int `json:"status"` + Headers map[string]string `json:"headers"` + Body string `json:"body"` +} + +// HookTask represents a hook task. +type HookTask struct { + ID int64 + RepoID int64 `xorm:"INDEX"` + HookID int64 + UUID string + Type HookTaskType + URL string `xorm:"TEXT"` + Signature string `xorm:"TEXT"` + api.Payloader `xorm:"-" json:"-"` + PayloadContent string `xorm:"TEXT"` + ContentType HookContentType + EventType HookEventType + IsSSL bool + IsDelivered bool + Delivered int64 + DeliveredString string `xorm:"-" json:"-"` + + // History info. + IsSucceed bool + RequestContent string `xorm:"TEXT"` + RequestInfo *HookRequest `xorm:"-" json:"-"` + ResponseContent string `xorm:"TEXT"` + ResponseInfo *HookResponse `xorm:"-" json:"-"` +} + +func (t *HookTask) BeforeUpdate() { + if t.RequestInfo != nil { + t.RequestContent = t.MarshalJSON(t.RequestInfo) + } + if t.ResponseInfo != nil { + t.ResponseContent = t.MarshalJSON(t.ResponseInfo) + } +} + +func (t *HookTask) AfterSet(colName string, _ xorm.Cell) { + var err error + switch colName { + case "delivered": + t.DeliveredString = time.Unix(0, t.Delivered).Format("2006-01-02 15:04:05 MST") + + case "request_content": + if len(t.RequestContent) == 0 { + return + } + + t.RequestInfo = &HookRequest{} + if err = jsoniter.Unmarshal([]byte(t.RequestContent), t.RequestInfo); err != nil { + log.Error(3, "Unmarshal[%d]: %v", t.ID, err) + } + + case "response_content": + if len(t.ResponseContent) == 0 { + return + } + + t.ResponseInfo = &HookResponse{} + if err = jsoniter.Unmarshal([]byte(t.ResponseContent), t.ResponseInfo); err != nil { + log.Error(3, "Unmarshal [%d]: %v", t.ID, err) + } + } +} + +func (t *HookTask) MarshalJSON(v interface{}) string { + p, err := jsoniter.Marshal(v) + if err != nil { + log.Error(3, "Marshal [%d]: %v", t.ID, err) + } + return string(p) +} + +// HookTasks returns a list of hook tasks by given conditions. +func HookTasks(hookID int64, page int) ([]*HookTask, error) { + tasks := make([]*HookTask, 0, setting.Webhook.PagingNum) + return tasks, x.Limit(setting.Webhook.PagingNum, (page-1)*setting.Webhook.PagingNum).Where("hook_id=?", hookID).Desc("id").Find(&tasks) +} + +// createHookTask creates a new hook task, +// it handles conversion from Payload to PayloadContent. +func createHookTask(e Engine, t *HookTask) error { + data, err := t.Payloader.JSONPayload() + if err != nil { + return err + } + t.UUID = gouuid.NewV4().String() + t.PayloadContent = string(data) + _, err = e.Insert(t) + return err +} + +// GetHookTaskOfWebhookByUUID returns hook task of given webhook by UUID. +func GetHookTaskOfWebhookByUUID(webhookID int64, uuid string) (*HookTask, error) { + hookTask := &HookTask{ + HookID: webhookID, + UUID: uuid, + } + has, err := x.Get(hookTask) + if err != nil { + return nil, err + } else if !has { + return nil, errors.HookTaskNotExist{webhookID, uuid} + } + return hookTask, nil +} + +// UpdateHookTask updates information of hook task. +func UpdateHookTask(t *HookTask) error { + _, err := x.Id(t.ID).AllCols().Update(t) + return err +} + +// prepareHookTasks adds list of webhooks to task queue. +func prepareHookTasks(e Engine, repo *Repository, event HookEventType, p api.Payloader, webhooks []*Webhook) (err error) { + if len(webhooks) == 0 { + return nil + } + + var payloader api.Payloader + for _, w := range webhooks { + switch event { + case HOOK_EVENT_CREATE: + if !w.HasCreateEvent() { + continue + } + case HOOK_EVENT_DELETE: + if !w.HasDeleteEvent() { + continue + } + case HOOK_EVENT_FORK: + if !w.HasForkEvent() { + continue + } + case HOOK_EVENT_PUSH: + if !w.HasPushEvent() { + continue + } + case HOOK_EVENT_ISSUES: + if !w.HasIssuesEvent() { + continue + } + case HOOK_EVENT_PULL_REQUEST: + if !w.HasPullRequestEvent() { + continue + } + case HOOK_EVENT_ISSUE_COMMENT: + if !w.HasIssueCommentEvent() { + continue + } + case HOOK_EVENT_RELEASE: + if !w.HasReleaseEvent() { + continue + } + } + + // Use separate objects so modifcations won't be made on payload on non-Gogs type hooks. + switch w.HookTaskType { + case SLACK: + payloader, err = GetSlackPayload(p, event, w.Meta) + if err != nil { + return fmt.Errorf("GetSlackPayload: %v", err) + } + case DISCORD: + payloader, err = GetDiscordPayload(p, event, w.Meta) + if err != nil { + return fmt.Errorf("GetDiscordPayload: %v", err) + } + case DINGTALK: + payloader, err = GetDingtalkPayload(p, event) + if err != nil { + return fmt.Errorf("GetDingtalkPayload: %v", err) + } + default: + payloader = p + } + + var signature string + if len(w.Secret) > 0 { + data, err := payloader.JSONPayload() + if err != nil { + log.Error(2, "prepareWebhooks.JSONPayload: %v", err) + } + sig := hmac.New(sha256.New, []byte(w.Secret)) + sig.Write(data) + signature = hex.EncodeToString(sig.Sum(nil)) + } + + if err = createHookTask(e, &HookTask{ + RepoID: repo.ID, + HookID: w.ID, + Type: w.HookTaskType, + URL: w.URL, + Signature: signature, + Payloader: payloader, + ContentType: w.ContentType, + EventType: event, + IsSSL: w.IsSSL, + }); err != nil { + return fmt.Errorf("createHookTask: %v", err) + } + } + + // It's safe to fail when the whole function is called during hook execution + // because resource released after exit. Also, there is no process started to + // consume this input during hook execution. + go HookQueue.Add(repo.ID) + return nil +} + +func prepareWebhooks(e Engine, repo *Repository, event HookEventType, p api.Payloader) error { + webhooks, err := getActiveWebhooksByRepoID(e, repo.ID) + if err != nil { + return fmt.Errorf("getActiveWebhooksByRepoID [%d]: %v", repo.ID, err) + } + + // check if repo belongs to org and append additional webhooks + if repo.mustOwner(e).IsOrganization() { + // get hooks for org + orgws, err := getActiveWebhooksByOrgID(e, repo.OwnerID) + if err != nil { + return fmt.Errorf("getActiveWebhooksByOrgID [%d]: %v", repo.OwnerID, err) + } + webhooks = append(webhooks, orgws...) + } + return prepareHookTasks(e, repo, event, p, webhooks) +} + +// PrepareWebhooks adds all active webhooks to task queue. +func PrepareWebhooks(repo *Repository, event HookEventType, p api.Payloader) error { + return prepareWebhooks(x, repo, event, p) +} + +// TestWebhook adds the test webhook matches the ID to task queue. +func TestWebhook(repo *Repository, event HookEventType, p api.Payloader, webhookID int64) error { + webhook, err := GetWebhookOfRepoByID(repo.ID, webhookID) + if err != nil { + return fmt.Errorf("GetWebhookOfRepoByID [repo_id: %d, id: %d]: %v", repo.ID, webhookID, err) + } + return prepareHookTasks(x, repo, event, p, []*Webhook{webhook}) +} + +func (t *HookTask) deliver() { + t.IsDelivered = true + + timeout := time.Duration(setting.Webhook.DeliverTimeout) * time.Second + req := httplib.Post(t.URL).SetTimeout(timeout, timeout). + Header("X-Github-Delivery", t.UUID). + Header("X-Github-Event", string(t.EventType)). + Header("X-Gogs-Delivery", t.UUID). + Header("X-Gogs-Signature", t.Signature). + Header("X-Gogs-Event", string(t.EventType)). + SetTLSClientConfig(&tls.Config{InsecureSkipVerify: setting.Webhook.SkipTLSVerify}) + + switch t.ContentType { + case JSON: + req = req.Header("Content-Type", "application/json").Body(t.PayloadContent) + case FORM: + req.Param("payload", t.PayloadContent) + } + + // Record delivery information. + t.RequestInfo = &HookRequest{ + Headers: map[string]string{}, + } + for k, vals := range req.Headers() { + t.RequestInfo.Headers[k] = strings.Join(vals, ",") + } + + t.ResponseInfo = &HookResponse{ + Headers: map[string]string{}, + } + + defer func() { + t.Delivered = time.Now().UnixNano() + if t.IsSucceed { + log.Trace("Hook delivered: %s", t.UUID) + } else { + log.Trace("Hook delivery failed: %s", t.UUID) + } + + // Update webhook last delivery status. + w, err := GetWebhookByID(t.HookID) + if err != nil { + log.Error(3, "GetWebhookByID: %v", err) + return + } + if t.IsSucceed { + w.LastStatus = HOOK_STATUS_SUCCEED + } else { + w.LastStatus = HOOK_STATUS_FAILED + } + if err = UpdateWebhook(w); err != nil { + log.Error(3, "UpdateWebhook: %v", err) + return + } + }() + + resp, err := req.Response() + if err != nil { + t.ResponseInfo.Body = fmt.Sprintf("Delivery: %v", err) + return + } + defer resp.Body.Close() + + // Status code is 20x can be seen as succeed. + t.IsSucceed = resp.StatusCode/100 == 2 + t.ResponseInfo.Status = resp.StatusCode + for k, vals := range resp.Header { + t.ResponseInfo.Headers[k] = strings.Join(vals, ",") + } + + p, err := ioutil.ReadAll(resp.Body) + if err != nil { + t.ResponseInfo.Body = fmt.Sprintf("read body: %s", err) + return + } + t.ResponseInfo.Body = string(p) +} + +// DeliverHooks checks and delivers undelivered hooks. +// TODO: shoot more hooks at same time. +func DeliverHooks() { + tasks := make([]*HookTask, 0, 10) + x.Where("is_delivered = ?", false).Iterate(new(HookTask), + func(idx int, bean interface{}) error { + t := bean.(*HookTask) + t.deliver() + tasks = append(tasks, t) + return nil + }) + + // Update hook task status. + for _, t := range tasks { + if err := UpdateHookTask(t); err != nil { + log.Error(4, "UpdateHookTask [%d]: %v", t.ID, err) + } + } + + // Start listening on new hook requests. + for repoID := range HookQueue.Queue() { + log.Trace("DeliverHooks [repo_id: %v]", repoID) + HookQueue.Remove(repoID) + + tasks = make([]*HookTask, 0, 5) + if err := x.Where("repo_id = ?", repoID).And("is_delivered = ?", false).Find(&tasks); err != nil { + log.Error(4, "Get repository [%s] hook tasks: %v", repoID, err) + continue + } + for _, t := range tasks { + t.deliver() + if err := UpdateHookTask(t); err != nil { + log.Error(4, "UpdateHookTask [%d]: %v", t.ID, err) + continue + } + } + } +} + +func InitDeliverHooks() { + go DeliverHooks() +} diff --git a/internal/db/webhook_dingtalk.go b/internal/db/webhook_dingtalk.go new file mode 100644 index 00000000..4382803d --- /dev/null +++ b/internal/db/webhook_dingtalk.go @@ -0,0 +1,261 @@ +// Copyright 2017 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package db + +import ( + "fmt" + "strings" + + "github.com/json-iterator/go" + + "github.com/gogs/git-module" + api "github.com/gogs/go-gogs-client" +) + +const ( + DingtalkNotificationTitle = "Gogs Notification" +) + +//Refer: https://open-doc.dingtalk.com/docs/doc.htm?treeId=257&articleId=105735&docType=1 +type DingtalkActionCard struct { + Title string `json:"title"` + Text string `json:"text"` + HideAvatar string `json:"hideAvatar"` + BtnOrientation string `json:"btnOrientation"` + SingleTitle string `json:"singleTitle"` + SingleURL string `json:"singleURL"` +} + +//Refer: https://open-doc.dingtalk.com/docs/doc.htm?treeId=257&articleId=105735&docType=1 +type DingtalkAtObject struct { + AtMobiles []string `json:"atMobiles"` + IsAtAll bool `json:"isAtAll"` +} + +//Refer: https://open-doc.dingtalk.com/docs/doc.htm?treeId=257&articleId=105735&docType=1 +type DingtalkPayload struct { + MsgType string `json:"msgtype"` + At DingtalkAtObject `json:"at"` + ActionCard DingtalkActionCard `json:"actionCard"` +} + +func (p *DingtalkPayload) JSONPayload() ([]byte, error) { + data, err := jsoniter.MarshalIndent(p, "", " ") + if err != nil { + return []byte{}, err + } + return data, nil +} + +func NewDingtalkActionCard(singleTitle, singleURL string) DingtalkActionCard { + return DingtalkActionCard{ + Title: DingtalkNotificationTitle, + SingleURL: singleURL, + SingleTitle: singleTitle, + } +} + +//TODO: add content +func GetDingtalkPayload(p api.Payloader, event HookEventType) (payload *DingtalkPayload, err error) { + switch event { + case HOOK_EVENT_CREATE: + payload, err = getDingtalkCreatePayload(p.(*api.CreatePayload)) + case HOOK_EVENT_DELETE: + payload, err = getDingtalkDeletePayload(p.(*api.DeletePayload)) + case HOOK_EVENT_FORK: + payload, err = getDingtalkForkPayload(p.(*api.ForkPayload)) + case HOOK_EVENT_PUSH: + payload, err = getDingtalkPushPayload(p.(*api.PushPayload)) + case HOOK_EVENT_ISSUES: + payload, err = getDingtalkIssuesPayload(p.(*api.IssuesPayload)) + case HOOK_EVENT_ISSUE_COMMENT: + payload, err = getDingtalkIssueCommentPayload(p.(*api.IssueCommentPayload)) + case HOOK_EVENT_PULL_REQUEST: + payload, err = getDingtalkPullRequestPayload(p.(*api.PullRequestPayload)) + case HOOK_EVENT_RELEASE: + payload, err = getDingtalkReleasePayload(p.(*api.ReleasePayload)) + } + + if err != nil { + return nil, fmt.Errorf("event '%s': %v", event, err) + } + + return payload, nil +} + +func getDingtalkCreatePayload(p *api.CreatePayload) (*DingtalkPayload, error) { + refName := git.RefEndName(p.Ref) + refType := strings.Title(p.RefType) + + actionCard := NewDingtalkActionCard("View "+refType, p.Repo.HTMLURL+"/src/"+refName) + + actionCard.Text += "# New " + refType + " Create Event" + actionCard.Text += "\n- Repo: **" + MarkdownLinkFormatter(p.Repo.HTMLURL, p.Repo.Name) + "**" + actionCard.Text += "\n- New " + refType + ": **" + MarkdownLinkFormatter(p.Repo.HTMLURL+"/src/"+refName, refName) + "**" + + return &DingtalkPayload{MsgType: "actionCard", ActionCard: actionCard}, nil +} + +func getDingtalkDeletePayload(p *api.DeletePayload) (*DingtalkPayload, error) { + refName := git.RefEndName(p.Ref) + refType := strings.Title(p.RefType) + + actionCard := NewDingtalkActionCard("View Repo", p.Repo.HTMLURL) + + actionCard.Text += "# " + refType + " Delete Event" + actionCard.Text += "\n- Repo: **" + MarkdownLinkFormatter(p.Repo.HTMLURL, p.Repo.Name) + "**" + actionCard.Text += "\n- " + refType + ": **" + refName + "**" + + return &DingtalkPayload{MsgType: "actionCard", ActionCard: actionCard}, nil +} + +func getDingtalkForkPayload(p *api.ForkPayload) (*DingtalkPayload, error) { + actionCard := NewDingtalkActionCard("View Forkee", p.Forkee.HTMLURL) + + actionCard.Text += "# Repo Fork Event" + actionCard.Text += "\n- From Repo: **" + MarkdownLinkFormatter(p.Repo.HTMLURL, p.Repo.Name) + "**" + actionCard.Text += "\n- To Repo: **" + MarkdownLinkFormatter(p.Forkee.HTMLURL, p.Forkee.FullName) + "**" + + return &DingtalkPayload{MsgType: "actionCard", ActionCard: actionCard}, nil +} + +func getDingtalkPushPayload(p *api.PushPayload) (*DingtalkPayload, error) { + refName := git.RefEndName(p.Ref) + + pusher := p.Pusher.FullName + if pusher == "" { + pusher = p.Pusher.UserName + } + + var detail string + for i, commit := range p.Commits { + msg := strings.Split(commit.Message, "\n")[0] + commitLink := MarkdownLinkFormatter(commit.URL, commit.ID[:7]) + detail += fmt.Sprintf("> %d. %s %s - %s\n", i, commitLink, commit.Author.Name, msg) + } + + actionCard := NewDingtalkActionCard("View Changes", p.CompareURL) + + actionCard.Text += "# Repo Push Event" + actionCard.Text += "\n- Repo: **" + MarkdownLinkFormatter(p.Repo.HTMLURL, p.Repo.Name) + "**" + actionCard.Text += "\n- Ref: **" + MarkdownLinkFormatter(p.Repo.HTMLURL+"/src/"+refName, refName) + "**" + actionCard.Text += "\n- Pusher: **" + pusher + "**" + actionCard.Text += "\n## " + fmt.Sprintf("Total %d commits(s)", len(p.Commits)) + actionCard.Text += "\n" + detail + + return &DingtalkPayload{MsgType: "actionCard", ActionCard: actionCard}, nil +} + +func getDingtalkIssuesPayload(p *api.IssuesPayload) (*DingtalkPayload, error) { + issueName := fmt.Sprintf("#%d %s", p.Index, p.Issue.Title) + issueURL := fmt.Sprintf("%s/issues/%d", p.Repository.HTMLURL, p.Index) + + actionCard := NewDingtalkActionCard("View Issue", issueURL) + + actionCard.Text += "# Issue Event " + strings.Title(string(p.Action)) + actionCard.Text += "\n- Issue: **" + MarkdownLinkFormatter(issueURL, issueName) + "**" + + if p.Action == api.HOOK_ISSUE_ASSIGNED { + actionCard.Text += "\n- New Assignee: **" + p.Issue.Assignee.UserName + "**" + } else if p.Action == api.HOOK_ISSUE_MILESTONED { + actionCard.Text += "\n- New Milestone: **" + p.Issue.Milestone.Title + "**" + } else if p.Action == api.HOOK_ISSUE_LABEL_UPDATED { + if len(p.Issue.Labels) > 0 { + labels := make([]string, len(p.Issue.Labels)) + for i, label := range p.Issue.Labels { + labels[i] = "**" + label.Name + "**" + } + actionCard.Text += "\n- Labels: " + strings.Join(labels, ",") + } else { + actionCard.Text += "\n- Labels: **empty**" + } + } + + if p.Issue.Body != "" { + actionCard.Text += "\n> " + p.Issue.Body + } + + return &DingtalkPayload{MsgType: "actionCard", ActionCard: actionCard}, nil +} + +func getDingtalkIssueCommentPayload(p *api.IssueCommentPayload) (*DingtalkPayload, error) { + issueName := fmt.Sprintf("#%d %s", p.Issue.Index, p.Issue.Title) + commentURL := fmt.Sprintf("%s/issues/%d", p.Repository.HTMLURL, p.Issue.Index) + if p.Action != api.HOOK_ISSUE_COMMENT_DELETED { + commentURL += "#" + CommentHashTag(p.Comment.ID) + } + + issueURL := fmt.Sprintf("%s/issues/%d", p.Repository.HTMLURL, p.Issue.Index) + + actionCard := NewDingtalkActionCard("View Issue Comment", commentURL) + + actionCard.Text += "# Issue Comment " + strings.Title(string(p.Action)) + actionCard.Text += "\n- Issue: " + MarkdownLinkFormatter(issueURL, issueName) + actionCard.Text += "\n- Comment content: " + actionCard.Text += "\n> " + p.Comment.Body + + return &DingtalkPayload{MsgType: "actionCard", ActionCard: actionCard}, nil +} + +func getDingtalkPullRequestPayload(p *api.PullRequestPayload) (*DingtalkPayload, error) { + title := "# Pull Request " + strings.Title(string(p.Action)) + if p.Action == api.HOOK_ISSUE_CLOSED && p.PullRequest.HasMerged { + title = "# Pull Request Merged" + } + + pullRequestURL := fmt.Sprintf("%s/pulls/%d", p.Repository.HTMLURL, p.Index) + + content := "- PR: " + MarkdownLinkFormatter(pullRequestURL, fmt.Sprintf("#%d %s", p.Index, p.PullRequest.Title)) + if p.Action == api.HOOK_ISSUE_ASSIGNED { + content += "\n- New Assignee: **" + p.PullRequest.Assignee.UserName + "**" + } else if p.Action == api.HOOK_ISSUE_MILESTONED { + content += "\n- New Milestone: *" + p.PullRequest.Milestone.Title + "*" + } else if p.Action == api.HOOK_ISSUE_LABEL_UPDATED { + labels := make([]string, len(p.PullRequest.Labels)) + for i, label := range p.PullRequest.Labels { + labels[i] = "**" + label.Name + "**" + } + content += "\n- New Labels: " + strings.Join(labels, ",") + } + + actionCard := NewDingtalkActionCard("View Pull Request", pullRequestURL) + actionCard.Text += title + "\n" + content + + if p.Action == api.HOOK_ISSUE_OPENED || p.Action == api.HOOK_ISSUE_EDITED { + actionCard.Text += "\n> " + p.PullRequest.Body + } + + return &DingtalkPayload{MsgType: "actionCard", ActionCard: actionCard}, nil +} + +func getDingtalkReleasePayload(p *api.ReleasePayload) (*DingtalkPayload, error) { + releaseURL := p.Repository.HTMLURL + "/src/" + p.Release.TagName + + author := p.Release.Author.FullName + if author == "" { + author = p.Release.Author.UserName + } + + actionCard := NewDingtalkActionCard("View Release", releaseURL) + + actionCard.Text += "# New Release Published" + actionCard.Text += "\n- Repo: " + MarkdownLinkFormatter(p.Repository.HTMLURL, p.Repository.Name) + actionCard.Text += "\n- Tag: " + MarkdownLinkFormatter(releaseURL, p.Release.TagName) + actionCard.Text += "\n- Author: " + author + actionCard.Text += fmt.Sprintf("\n- Draft?: %t", p.Release.Draft) + actionCard.Text += fmt.Sprintf("\n- Pre Release?: %t", p.Release.Prerelease) + actionCard.Text += "\n- Title: " + p.Release.Name + + if p.Release.Body != "" { + actionCard.Text += "\n- Note: " + p.Release.Body + } + + return &DingtalkPayload{MsgType: "actionCard", ActionCard: actionCard}, nil +} + +//Format link addr and title into markdown style +func MarkdownLinkFormatter(link, text string) string { + return "[" + text + "](" + link + ")" +} diff --git a/internal/db/webhook_discord.go b/internal/db/webhook_discord.go new file mode 100644 index 00000000..35b7d9b1 --- /dev/null +++ b/internal/db/webhook_discord.go @@ -0,0 +1,409 @@ +// Copyright 2017 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package db + +import ( + "fmt" + "strconv" + "strings" + + "github.com/json-iterator/go" + + "github.com/gogs/git-module" + api "github.com/gogs/go-gogs-client" + + "gogs.io/gogs/internal/setting" +) + +type DiscordEmbedFooterObject struct { + Text string `json:"text"` +} + +type DiscordEmbedAuthorObject struct { + Name string `json:"name"` + URL string `json:"url"` + IconURL string `json:"icon_url"` +} + +type DiscordEmbedFieldObject struct { + Name string `json:"name"` + Value string `json:"value"` +} + +type DiscordEmbedObject struct { + Title string `json:"title"` + Description string `json:"description"` + URL string `json:"url"` + Color int `json:"color"` + Footer *DiscordEmbedFooterObject `json:"footer"` + Author *DiscordEmbedAuthorObject `json:"author"` + Fields []*DiscordEmbedFieldObject `json:"fields"` +} + +type DiscordPayload struct { + Content string `json:"content"` + Username string `json:"username"` + AvatarURL string `json:"avatar_url"` + Embeds []*DiscordEmbedObject `json:"embeds"` +} + +func (p *DiscordPayload) JSONPayload() ([]byte, error) { + data, err := jsoniter.MarshalIndent(p, "", " ") + if err != nil { + return []byte{}, err + } + return data, nil +} + +func DiscordTextFormatter(s string) string { + return strings.Split(s, "\n")[0] +} + +func DiscordLinkFormatter(url string, text string) string { + return fmt.Sprintf("[%s](%s)", text, url) +} + +func DiscordSHALinkFormatter(url string, text string) string { + return fmt.Sprintf("[`%s`](%s)", text, url) +} + +// getDiscordCreatePayload composes Discord payload for create new branch or tag. +func getDiscordCreatePayload(p *api.CreatePayload) (*DiscordPayload, error) { + refName := git.RefEndName(p.Ref) + repoLink := DiscordLinkFormatter(p.Repo.HTMLURL, p.Repo.Name) + refLink := DiscordLinkFormatter(p.Repo.HTMLURL+"/src/"+refName, refName) + content := fmt.Sprintf("Created new %s: %s/%s", p.RefType, repoLink, refLink) + return &DiscordPayload{ + Embeds: []*DiscordEmbedObject{{ + Description: content, + URL: setting.AppURL + p.Sender.UserName, + Author: &DiscordEmbedAuthorObject{ + Name: p.Sender.UserName, + IconURL: p.Sender.AvatarUrl, + }, + }}, + }, nil +} + +// getDiscordDeletePayload composes Discord payload for delete a branch or tag. +func getDiscordDeletePayload(p *api.DeletePayload) (*DiscordPayload, error) { + refName := git.RefEndName(p.Ref) + repoLink := DiscordLinkFormatter(p.Repo.HTMLURL, p.Repo.Name) + content := fmt.Sprintf("Deleted %s: %s/%s", p.RefType, repoLink, refName) + return &DiscordPayload{ + Embeds: []*DiscordEmbedObject{{ + Description: content, + URL: setting.AppURL + p.Sender.UserName, + Author: &DiscordEmbedAuthorObject{ + Name: p.Sender.UserName, + IconURL: p.Sender.AvatarUrl, + }, + }}, + }, nil +} + +// getDiscordForkPayload composes Discord payload for forked by a repository. +func getDiscordForkPayload(p *api.ForkPayload) (*DiscordPayload, error) { + baseLink := DiscordLinkFormatter(p.Repo.HTMLURL, p.Repo.Name) + forkLink := DiscordLinkFormatter(p.Forkee.HTMLURL, p.Forkee.FullName) + content := fmt.Sprintf("%s is forked to %s", baseLink, forkLink) + return &DiscordPayload{ + Embeds: []*DiscordEmbedObject{{ + Description: content, + URL: setting.AppURL + p.Sender.UserName, + Author: &DiscordEmbedAuthorObject{ + Name: p.Sender.UserName, + IconURL: p.Sender.AvatarUrl, + }, + }}, + }, nil +} + +func getDiscordPushPayload(p *api.PushPayload, slack *SlackMeta) (*DiscordPayload, error) { + // n new commits + var ( + branchName = git.RefEndName(p.Ref) + commitDesc string + commitString string + ) + + if len(p.Commits) == 1 { + commitDesc = "1 new commit" + } else { + commitDesc = fmt.Sprintf("%d new commits", len(p.Commits)) + } + + if len(p.CompareURL) > 0 { + commitString = DiscordLinkFormatter(p.CompareURL, commitDesc) + } else { + commitString = commitDesc + } + + repoLink := DiscordLinkFormatter(p.Repo.HTMLURL, p.Repo.Name) + branchLink := DiscordLinkFormatter(p.Repo.HTMLURL+"/src/"+branchName, branchName) + content := fmt.Sprintf("Pushed %s to %s/%s\n", commitString, repoLink, branchLink) + + // for each commit, generate attachment text + for i, commit := range p.Commits { + content += fmt.Sprintf("%s %s - %s", DiscordSHALinkFormatter(commit.URL, commit.ID[:7]), DiscordTextFormatter(commit.Message), commit.Author.Name) + // add linebreak to each commit but the last + if i < len(p.Commits)-1 { + content += "\n" + } + } + + color, _ := strconv.ParseInt(strings.TrimLeft(slack.Color, "#"), 16, 32) + return &DiscordPayload{ + Username: slack.Username, + AvatarURL: slack.IconURL, + Embeds: []*DiscordEmbedObject{{ + Description: content, + URL: setting.AppURL + p.Sender.UserName, + Color: int(color), + Author: &DiscordEmbedAuthorObject{ + Name: p.Sender.UserName, + IconURL: p.Sender.AvatarUrl, + }, + }}, + }, nil +} + +func getDiscordIssuesPayload(p *api.IssuesPayload, slack *SlackMeta) (*DiscordPayload, error) { + title := fmt.Sprintf("#%d %s", p.Index, p.Issue.Title) + url := fmt.Sprintf("%s/issues/%d", p.Repository.HTMLURL, p.Index) + content := "" + fields := make([]*DiscordEmbedFieldObject, 0, 1) + switch p.Action { + case api.HOOK_ISSUE_OPENED: + title = "New issue: " + title + content = p.Issue.Body + case api.HOOK_ISSUE_CLOSED: + title = "Issue closed: " + title + case api.HOOK_ISSUE_REOPENED: + title = "Issue re-opened: " + title + case api.HOOK_ISSUE_EDITED: + title = "Issue edited: " + title + content = p.Issue.Body + case api.HOOK_ISSUE_ASSIGNED: + title = "Issue assigned: " + title + fields = []*DiscordEmbedFieldObject{{ + Name: "New Assignee", + Value: p.Issue.Assignee.UserName, + }} + case api.HOOK_ISSUE_UNASSIGNED: + title = "Issue unassigned: " + title + case api.HOOK_ISSUE_LABEL_UPDATED: + title = "Issue labels updated: " + title + labels := make([]string, len(p.Issue.Labels)) + for i := range p.Issue.Labels { + labels[i] = p.Issue.Labels[i].Name + } + if len(labels) == 0 { + labels = []string{"<empty>"} + } + fields = []*DiscordEmbedFieldObject{{ + Name: "Labels", + Value: strings.Join(labels, ", "), + }} + case api.HOOK_ISSUE_LABEL_CLEARED: + title = "Issue labels cleared: " + title + case api.HOOK_ISSUE_SYNCHRONIZED: + title = "Issue synchronized: " + title + case api.HOOK_ISSUE_MILESTONED: + title = "Issue milestoned: " + title + fields = []*DiscordEmbedFieldObject{{ + Name: "New Milestone", + Value: p.Issue.Milestone.Title, + }} + case api.HOOK_ISSUE_DEMILESTONED: + title = "Issue demilestoned: " + title + } + + color, _ := strconv.ParseInt(strings.TrimLeft(slack.Color, "#"), 16, 32) + return &DiscordPayload{ + Username: slack.Username, + AvatarURL: slack.IconURL, + Embeds: []*DiscordEmbedObject{{ + Title: title, + Description: content, + URL: url, + Color: int(color), + Footer: &DiscordEmbedFooterObject{ + Text: p.Repository.FullName, + }, + Author: &DiscordEmbedAuthorObject{ + Name: p.Sender.UserName, + IconURL: p.Sender.AvatarUrl, + }, + Fields: fields, + }}, + }, nil +} + +func getDiscordIssueCommentPayload(p *api.IssueCommentPayload, slack *SlackMeta) (*DiscordPayload, error) { + title := fmt.Sprintf("#%d %s", p.Issue.Index, p.Issue.Title) + url := fmt.Sprintf("%s/issues/%d#%s", p.Repository.HTMLURL, p.Issue.Index, CommentHashTag(p.Comment.ID)) + content := "" + fields := make([]*DiscordEmbedFieldObject, 0, 1) + switch p.Action { + case api.HOOK_ISSUE_COMMENT_CREATED: + title = "New comment: " + title + content = p.Comment.Body + case api.HOOK_ISSUE_COMMENT_EDITED: + title = "Comment edited: " + title + content = p.Comment.Body + case api.HOOK_ISSUE_COMMENT_DELETED: + title = "Comment deleted: " + title + url = fmt.Sprintf("%s/issues/%d", p.Repository.HTMLURL, p.Issue.Index) + content = p.Comment.Body + } + + color, _ := strconv.ParseInt(strings.TrimLeft(slack.Color, "#"), 16, 32) + return &DiscordPayload{ + Username: slack.Username, + AvatarURL: slack.IconURL, + Embeds: []*DiscordEmbedObject{{ + Title: title, + Description: content, + URL: url, + Color: int(color), + Footer: &DiscordEmbedFooterObject{ + Text: p.Repository.FullName, + }, + Author: &DiscordEmbedAuthorObject{ + Name: p.Sender.UserName, + IconURL: p.Sender.AvatarUrl, + }, + Fields: fields, + }}, + }, nil +} + +func getDiscordPullRequestPayload(p *api.PullRequestPayload, slack *SlackMeta) (*DiscordPayload, error) { + title := fmt.Sprintf("#%d %s", p.Index, p.PullRequest.Title) + url := fmt.Sprintf("%s/pulls/%d", p.Repository.HTMLURL, p.Index) + content := "" + fields := make([]*DiscordEmbedFieldObject, 0, 1) + switch p.Action { + case api.HOOK_ISSUE_OPENED: + title = "New pull request: " + title + content = p.PullRequest.Body + case api.HOOK_ISSUE_CLOSED: + if p.PullRequest.HasMerged { + title = "Pull request merged: " + title + } else { + title = "Pull request closed: " + title + } + case api.HOOK_ISSUE_REOPENED: + title = "Pull request re-opened: " + title + case api.HOOK_ISSUE_EDITED: + title = "Pull request edited: " + title + content = p.PullRequest.Body + case api.HOOK_ISSUE_ASSIGNED: + title = "Pull request assigned: " + title + fields = []*DiscordEmbedFieldObject{{ + Name: "New Assignee", + Value: p.PullRequest.Assignee.UserName, + }} + case api.HOOK_ISSUE_UNASSIGNED: + title = "Pull request unassigned: " + title + case api.HOOK_ISSUE_LABEL_UPDATED: + title = "Pull request labels updated: " + title + labels := make([]string, len(p.PullRequest.Labels)) + for i := range p.PullRequest.Labels { + labels[i] = p.PullRequest.Labels[i].Name + } + fields = []*DiscordEmbedFieldObject{{ + Name: "Labels", + Value: strings.Join(labels, ", "), + }} + case api.HOOK_ISSUE_LABEL_CLEARED: + title = "Pull request labels cleared: " + title + case api.HOOK_ISSUE_SYNCHRONIZED: + title = "Pull request synchronized: " + title + case api.HOOK_ISSUE_MILESTONED: + title = "Pull request milestoned: " + title + fields = []*DiscordEmbedFieldObject{{ + Name: "New Milestone", + Value: p.PullRequest.Milestone.Title, + }} + case api.HOOK_ISSUE_DEMILESTONED: + title = "Pull request demilestoned: " + title + } + + color, _ := strconv.ParseInt(strings.TrimLeft(slack.Color, "#"), 16, 32) + return &DiscordPayload{ + Username: slack.Username, + AvatarURL: slack.IconURL, + Embeds: []*DiscordEmbedObject{{ + Title: title, + Description: content, + URL: url, + Color: int(color), + Footer: &DiscordEmbedFooterObject{ + Text: p.Repository.FullName, + }, + Author: &DiscordEmbedAuthorObject{ + Name: p.Sender.UserName, + IconURL: p.Sender.AvatarUrl, + }, + Fields: fields, + }}, + }, nil +} + +func getDiscordReleasePayload(p *api.ReleasePayload) (*DiscordPayload, error) { + repoLink := DiscordLinkFormatter(p.Repository.HTMLURL, p.Repository.Name) + refLink := DiscordLinkFormatter(p.Repository.HTMLURL+"/src/"+p.Release.TagName, p.Release.TagName) + content := fmt.Sprintf("Published new release %s of %s", refLink, repoLink) + return &DiscordPayload{ + Embeds: []*DiscordEmbedObject{{ + Description: content, + URL: setting.AppURL + p.Sender.UserName, + Author: &DiscordEmbedAuthorObject{ + Name: p.Sender.UserName, + IconURL: p.Sender.AvatarUrl, + }, + }}, + }, nil +} + +func GetDiscordPayload(p api.Payloader, event HookEventType, meta string) (payload *DiscordPayload, err error) { + slack := &SlackMeta{} + if err := jsoniter.Unmarshal([]byte(meta), &slack); err != nil { + return nil, fmt.Errorf("jsoniter.Unmarshal: %v", err) + } + + switch event { + case HOOK_EVENT_CREATE: + payload, err = getDiscordCreatePayload(p.(*api.CreatePayload)) + case HOOK_EVENT_DELETE: + payload, err = getDiscordDeletePayload(p.(*api.DeletePayload)) + case HOOK_EVENT_FORK: + payload, err = getDiscordForkPayload(p.(*api.ForkPayload)) + case HOOK_EVENT_PUSH: + payload, err = getDiscordPushPayload(p.(*api.PushPayload), slack) + case HOOK_EVENT_ISSUES: + payload, err = getDiscordIssuesPayload(p.(*api.IssuesPayload), slack) + case HOOK_EVENT_ISSUE_COMMENT: + payload, err = getDiscordIssueCommentPayload(p.(*api.IssueCommentPayload), slack) + case HOOK_EVENT_PULL_REQUEST: + payload, err = getDiscordPullRequestPayload(p.(*api.PullRequestPayload), slack) + case HOOK_EVENT_RELEASE: + payload, err = getDiscordReleasePayload(p.(*api.ReleasePayload)) + } + if err != nil { + return nil, fmt.Errorf("event '%s': %v", event, err) + } + + payload.Username = slack.Username + payload.AvatarURL = slack.IconURL + if len(payload.Embeds) > 0 { + color, _ := strconv.ParseInt(strings.TrimLeft(slack.Color, "#"), 16, 32) + payload.Embeds[0].Color = int(color) + } + + return payload, nil +} diff --git a/internal/db/webhook_slack.go b/internal/db/webhook_slack.go new file mode 100644 index 00000000..ae547dd7 --- /dev/null +++ b/internal/db/webhook_slack.go @@ -0,0 +1,326 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package db + +import ( + "fmt" + "strings" + + "github.com/json-iterator/go" + + "github.com/gogs/git-module" + api "github.com/gogs/go-gogs-client" + + "gogs.io/gogs/internal/setting" +) + +type SlackMeta struct { + Channel string `json:"channel"` + Username string `json:"username"` + IconURL string `json:"icon_url"` + Color string `json:"color"` +} + +type SlackAttachment struct { + Fallback string `json:"fallback"` + Color string `json:"color"` + Title string `json:"title"` + Text string `json:"text"` +} + +type SlackPayload struct { + Channel string `json:"channel"` + Text string `json:"text"` + Username string `json:"username"` + IconURL string `json:"icon_url"` + UnfurlLinks int `json:"unfurl_links"` + LinkNames int `json:"link_names"` + Attachments []*SlackAttachment `json:"attachments"` +} + +func (p *SlackPayload) JSONPayload() ([]byte, error) { + data, err := jsoniter.MarshalIndent(p, "", " ") + if err != nil { + return []byte{}, err + } + return data, nil +} + +// see: https://api.slack.com/docs/formatting +func SlackTextFormatter(s string) string { + // replace & < > + s = strings.Replace(s, "&", "&", -1) + s = strings.Replace(s, "<", "<", -1) + s = strings.Replace(s, ">", ">", -1) + return s +} + +func SlackShortTextFormatter(s string) string { + s = strings.Split(s, "\n")[0] + // replace & < > + s = strings.Replace(s, "&", "&", -1) + s = strings.Replace(s, "<", "<", -1) + s = strings.Replace(s, ">", ">", -1) + return s +} + +func SlackLinkFormatter(url string, text string) string { + return fmt.Sprintf("<%s|%s>", url, SlackTextFormatter(text)) +} + +// getSlackCreatePayload composes Slack payload for create new branch or tag. +func getSlackCreatePayload(p *api.CreatePayload) (*SlackPayload, error) { + refName := git.RefEndName(p.Ref) + repoLink := SlackLinkFormatter(p.Repo.HTMLURL, p.Repo.Name) + refLink := SlackLinkFormatter(p.Repo.HTMLURL+"/src/"+refName, refName) + text := fmt.Sprintf("[%s:%s] %s created by %s", repoLink, refLink, p.RefType, p.Sender.UserName) + return &SlackPayload{ + Text: text, + }, nil +} + +// getSlackDeletePayload composes Slack payload for delete a branch or tag. +func getSlackDeletePayload(p *api.DeletePayload) (*SlackPayload, error) { + refName := git.RefEndName(p.Ref) + repoLink := SlackLinkFormatter(p.Repo.HTMLURL, p.Repo.Name) + text := fmt.Sprintf("[%s:%s] %s deleted by %s", repoLink, refName, p.RefType, p.Sender.UserName) + return &SlackPayload{ + Text: text, + }, nil +} + +// getSlackForkPayload composes Slack payload for forked by a repository. +func getSlackForkPayload(p *api.ForkPayload) (*SlackPayload, error) { + baseLink := SlackLinkFormatter(p.Repo.HTMLURL, p.Repo.Name) + forkLink := SlackLinkFormatter(p.Forkee.HTMLURL, p.Forkee.FullName) + text := fmt.Sprintf("%s is forked to %s", baseLink, forkLink) + return &SlackPayload{ + Text: text, + }, nil +} + +func getSlackPushPayload(p *api.PushPayload, slack *SlackMeta) (*SlackPayload, error) { + // n new commits + var ( + branchName = git.RefEndName(p.Ref) + commitDesc string + commitString string + ) + + if len(p.Commits) == 1 { + commitDesc = "1 new commit" + } else { + commitDesc = fmt.Sprintf("%d new commits", len(p.Commits)) + } + if len(p.CompareURL) > 0 { + commitString = SlackLinkFormatter(p.CompareURL, commitDesc) + } else { + commitString = commitDesc + } + + repoLink := SlackLinkFormatter(p.Repo.HTMLURL, p.Repo.Name) + branchLink := SlackLinkFormatter(p.Repo.HTMLURL+"/src/"+branchName, branchName) + text := fmt.Sprintf("[%s:%s] %s pushed by %s", repoLink, branchLink, commitString, p.Pusher.UserName) + + var attachmentText string + // for each commit, generate attachment text + for i, commit := range p.Commits { + attachmentText += fmt.Sprintf("%s: %s - %s", SlackLinkFormatter(commit.URL, commit.ID[:7]), SlackShortTextFormatter(commit.Message), SlackTextFormatter(commit.Author.Name)) + // add linebreak to each commit but the last + if i < len(p.Commits)-1 { + attachmentText += "\n" + } + } + + return &SlackPayload{ + Channel: slack.Channel, + Text: text, + Username: slack.Username, + IconURL: slack.IconURL, + Attachments: []*SlackAttachment{{ + Color: slack.Color, + Text: attachmentText, + }}, + }, nil +} + +func getSlackIssuesPayload(p *api.IssuesPayload, slack *SlackMeta) (*SlackPayload, error) { + senderLink := SlackLinkFormatter(setting.AppURL+p.Sender.UserName, p.Sender.UserName) + titleLink := SlackLinkFormatter(fmt.Sprintf("%s/issues/%d", p.Repository.HTMLURL, p.Index), + fmt.Sprintf("#%d %s", p.Index, p.Issue.Title)) + var text, title, attachmentText string + switch p.Action { + case api.HOOK_ISSUE_OPENED: + text = fmt.Sprintf("[%s] New issue created by %s", p.Repository.FullName, senderLink) + title = titleLink + attachmentText = SlackTextFormatter(p.Issue.Body) + case api.HOOK_ISSUE_CLOSED: + text = fmt.Sprintf("[%s] Issue closed: %s by %s", p.Repository.FullName, titleLink, senderLink) + case api.HOOK_ISSUE_REOPENED: + text = fmt.Sprintf("[%s] Issue re-opened: %s by %s", p.Repository.FullName, titleLink, senderLink) + case api.HOOK_ISSUE_EDITED: + text = fmt.Sprintf("[%s] Issue edited: %s by %s", p.Repository.FullName, titleLink, senderLink) + attachmentText = SlackTextFormatter(p.Issue.Body) + case api.HOOK_ISSUE_ASSIGNED: + text = fmt.Sprintf("[%s] Issue assigned to %s: %s by %s", p.Repository.FullName, + SlackLinkFormatter(setting.AppURL+p.Issue.Assignee.UserName, p.Issue.Assignee.UserName), + titleLink, senderLink) + case api.HOOK_ISSUE_UNASSIGNED: + text = fmt.Sprintf("[%s] Issue unassigned: %s by %s", p.Repository.FullName, titleLink, senderLink) + case api.HOOK_ISSUE_LABEL_UPDATED: + text = fmt.Sprintf("[%s] Issue labels updated: %s by %s", p.Repository.FullName, titleLink, senderLink) + case api.HOOK_ISSUE_LABEL_CLEARED: + text = fmt.Sprintf("[%s] Issue labels cleared: %s by %s", p.Repository.FullName, titleLink, senderLink) + case api.HOOK_ISSUE_MILESTONED: + text = fmt.Sprintf("[%s] Issue milestoned: %s by %s", p.Repository.FullName, titleLink, senderLink) + case api.HOOK_ISSUE_DEMILESTONED: + text = fmt.Sprintf("[%s] Issue demilestoned: %s by %s", p.Repository.FullName, titleLink, senderLink) + } + + return &SlackPayload{ + Channel: slack.Channel, + Text: text, + Username: slack.Username, + IconURL: slack.IconURL, + Attachments: []*SlackAttachment{{ + Color: slack.Color, + Title: title, + Text: attachmentText, + }}, + }, nil +} + +func getSlackIssueCommentPayload(p *api.IssueCommentPayload, slack *SlackMeta) (*SlackPayload, error) { + senderLink := SlackLinkFormatter(setting.AppURL+p.Sender.UserName, p.Sender.UserName) + titleLink := SlackLinkFormatter(fmt.Sprintf("%s/issues/%d#%s", p.Repository.HTMLURL, p.Issue.Index, CommentHashTag(p.Comment.ID)), + fmt.Sprintf("#%d %s", p.Issue.Index, p.Issue.Title)) + var text, title, attachmentText string + switch p.Action { + case api.HOOK_ISSUE_COMMENT_CREATED: + text = fmt.Sprintf("[%s] New comment created by %s", p.Repository.FullName, senderLink) + title = titleLink + attachmentText = SlackTextFormatter(p.Comment.Body) + case api.HOOK_ISSUE_COMMENT_EDITED: + text = fmt.Sprintf("[%s] Comment edited by %s", p.Repository.FullName, senderLink) + title = titleLink + attachmentText = SlackTextFormatter(p.Comment.Body) + case api.HOOK_ISSUE_COMMENT_DELETED: + text = fmt.Sprintf("[%s] Comment deleted by %s", p.Repository.FullName, senderLink) + title = SlackLinkFormatter(fmt.Sprintf("%s/issues/%d", p.Repository.HTMLURL, p.Issue.Index), + fmt.Sprintf("#%d %s", p.Issue.Index, p.Issue.Title)) + attachmentText = SlackTextFormatter(p.Comment.Body) + } + + return &SlackPayload{ + Channel: slack.Channel, + Text: text, + Username: slack.Username, + IconURL: slack.IconURL, + Attachments: []*SlackAttachment{{ + Color: slack.Color, + Title: title, + Text: attachmentText, + }}, + }, nil +} + +func getSlackPullRequestPayload(p *api.PullRequestPayload, slack *SlackMeta) (*SlackPayload, error) { + senderLink := SlackLinkFormatter(setting.AppURL+p.Sender.UserName, p.Sender.UserName) + titleLink := SlackLinkFormatter(fmt.Sprintf("%s/pulls/%d", p.Repository.HTMLURL, p.Index), + fmt.Sprintf("#%d %s", p.Index, p.PullRequest.Title)) + var text, title, attachmentText string + switch p.Action { + case api.HOOK_ISSUE_OPENED: + text = fmt.Sprintf("[%s] Pull request submitted by %s", p.Repository.FullName, senderLink) + title = titleLink + attachmentText = SlackTextFormatter(p.PullRequest.Body) + case api.HOOK_ISSUE_CLOSED: + if p.PullRequest.HasMerged { + text = fmt.Sprintf("[%s] Pull request merged: %s by %s", p.Repository.FullName, titleLink, senderLink) + } else { + text = fmt.Sprintf("[%s] Pull request closed: %s by %s", p.Repository.FullName, titleLink, senderLink) + } + case api.HOOK_ISSUE_REOPENED: + text = fmt.Sprintf("[%s] Pull request re-opened: %s by %s", p.Repository.FullName, titleLink, senderLink) + case api.HOOK_ISSUE_EDITED: + text = fmt.Sprintf("[%s] Pull request edited: %s by %s", p.Repository.FullName, titleLink, senderLink) + attachmentText = SlackTextFormatter(p.PullRequest.Body) + case api.HOOK_ISSUE_ASSIGNED: + text = fmt.Sprintf("[%s] Pull request assigned to %s: %s by %s", p.Repository.FullName, + SlackLinkFormatter(setting.AppURL+p.PullRequest.Assignee.UserName, p.PullRequest.Assignee.UserName), + titleLink, senderLink) + case api.HOOK_ISSUE_UNASSIGNED: + text = fmt.Sprintf("[%s] Pull request unassigned: %s by %s", p.Repository.FullName, titleLink, senderLink) + case api.HOOK_ISSUE_LABEL_UPDATED: + text = fmt.Sprintf("[%s] Pull request labels updated: %s by %s", p.Repository.FullName, titleLink, senderLink) + case api.HOOK_ISSUE_LABEL_CLEARED: + text = fmt.Sprintf("[%s] Pull request labels cleared: %s by %s", p.Repository.FullName, titleLink, senderLink) + case api.HOOK_ISSUE_SYNCHRONIZED: + text = fmt.Sprintf("[%s] Pull request synchronized: %s by %s", p.Repository.FullName, titleLink, senderLink) + case api.HOOK_ISSUE_MILESTONED: + text = fmt.Sprintf("[%s] Pull request milestoned: %s by %s", p.Repository.FullName, titleLink, senderLink) + case api.HOOK_ISSUE_DEMILESTONED: + text = fmt.Sprintf("[%s] Pull request demilestoned: %s by %s", p.Repository.FullName, titleLink, senderLink) + } + + return &SlackPayload{ + Channel: slack.Channel, + Text: text, + Username: slack.Username, + IconURL: slack.IconURL, + Attachments: []*SlackAttachment{{ + Color: slack.Color, + Title: title, + Text: attachmentText, + }}, + }, nil +} + +func getSlackReleasePayload(p *api.ReleasePayload) (*SlackPayload, error) { + repoLink := SlackLinkFormatter(p.Repository.HTMLURL, p.Repository.Name) + refLink := SlackLinkFormatter(p.Repository.HTMLURL+"/src/"+p.Release.TagName, p.Release.TagName) + text := fmt.Sprintf("[%s] new release %s published by %s", repoLink, refLink, p.Sender.UserName) + return &SlackPayload{ + Text: text, + }, nil +} + +func GetSlackPayload(p api.Payloader, event HookEventType, meta string) (payload *SlackPayload, err error) { + slack := &SlackMeta{} + if err := jsoniter.Unmarshal([]byte(meta), &slack); err != nil { + return nil, fmt.Errorf("Unmarshal: %v", err) + } + + switch event { + case HOOK_EVENT_CREATE: + payload, err = getSlackCreatePayload(p.(*api.CreatePayload)) + case HOOK_EVENT_DELETE: + payload, err = getSlackDeletePayload(p.(*api.DeletePayload)) + case HOOK_EVENT_FORK: + payload, err = getSlackForkPayload(p.(*api.ForkPayload)) + case HOOK_EVENT_PUSH: + payload, err = getSlackPushPayload(p.(*api.PushPayload), slack) + case HOOK_EVENT_ISSUES: + payload, err = getSlackIssuesPayload(p.(*api.IssuesPayload), slack) + case HOOK_EVENT_ISSUE_COMMENT: + payload, err = getSlackIssueCommentPayload(p.(*api.IssueCommentPayload), slack) + case HOOK_EVENT_PULL_REQUEST: + payload, err = getSlackPullRequestPayload(p.(*api.PullRequestPayload), slack) + case HOOK_EVENT_RELEASE: + payload, err = getSlackReleasePayload(p.(*api.ReleasePayload)) + } + if err != nil { + return nil, fmt.Errorf("event '%s': %v", event, err) + } + + payload.Channel = slack.Channel + payload.Username = slack.Username + payload.IconURL = slack.IconURL + if len(payload.Attachments) > 0 { + payload.Attachments[0].Color = slack.Color + } + + return payload, nil +} diff --git a/internal/db/wiki.go b/internal/db/wiki.go new file mode 100644 index 00000000..a7e27418 --- /dev/null +++ b/internal/db/wiki.go @@ -0,0 +1,179 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package db + +import ( + "fmt" + "io/ioutil" + "net/url" + "os" + "path" + "path/filepath" + "strings" + + "github.com/unknwon/com" + + "github.com/gogs/git-module" + + "gogs.io/gogs/internal/setting" + "gogs.io/gogs/internal/sync" +) + +var wikiWorkingPool = sync.NewExclusivePool() + +// ToWikiPageURL formats a string to corresponding wiki URL name. +func ToWikiPageURL(name string) string { + return url.QueryEscape(name) +} + +// ToWikiPageName formats a URL back to corresponding wiki page name, +// and removes leading characters './' to prevent changing files +// that are not belong to wiki repository. +func ToWikiPageName(urlString string) string { + name, _ := url.QueryUnescape(urlString) + return strings.Replace(strings.TrimLeft(path.Clean("/"+name), "/"), "/", " ", -1) +} + +// WikiCloneLink returns clone URLs of repository wiki. +func (repo *Repository) WikiCloneLink() (cl *CloneLink) { + return repo.cloneLink(true) +} + +// WikiPath returns wiki data path by given user and repository name. +func WikiPath(userName, repoName string) string { + return filepath.Join(UserPath(userName), strings.ToLower(repoName)+".wiki.git") +} + +func (repo *Repository) WikiPath() string { + return WikiPath(repo.MustOwner().Name, repo.Name) +} + +// HasWiki returns true if repository has wiki. +func (repo *Repository) HasWiki() bool { + return com.IsDir(repo.WikiPath()) +} + +// InitWiki initializes a wiki for repository, +// it does nothing when repository already has wiki. +func (repo *Repository) InitWiki() error { + if repo.HasWiki() { + return nil + } + + if err := git.InitRepository(repo.WikiPath(), true); err != nil { + return fmt.Errorf("InitRepository: %v", err) + } else if err = createDelegateHooks(repo.WikiPath()); err != nil { + return fmt.Errorf("createDelegateHooks: %v", err) + } + return nil +} + +func (repo *Repository) LocalWikiPath() string { + return path.Join(setting.AppDataPath, "tmp/local-wiki", com.ToStr(repo.ID)) +} + +// UpdateLocalWiki makes sure the local copy of repository wiki is up-to-date. +func (repo *Repository) UpdateLocalWiki() error { + return UpdateLocalCopyBranch(repo.WikiPath(), repo.LocalWikiPath(), "master", true) +} + +func discardLocalWikiChanges(localPath string) error { + return discardLocalRepoBranchChanges(localPath, "master") +} + +// updateWikiPage adds new page to repository wiki. +func (repo *Repository) updateWikiPage(doer *User, oldTitle, title, content, message string, isNew bool) (err error) { + wikiWorkingPool.CheckIn(com.ToStr(repo.ID)) + defer wikiWorkingPool.CheckOut(com.ToStr(repo.ID)) + + if err = repo.InitWiki(); err != nil { + return fmt.Errorf("InitWiki: %v", err) + } + + localPath := repo.LocalWikiPath() + if err = discardLocalWikiChanges(localPath); err != nil { + return fmt.Errorf("discardLocalWikiChanges: %v", err) + } else if err = repo.UpdateLocalWiki(); err != nil { + return fmt.Errorf("UpdateLocalWiki: %v", err) + } + + title = ToWikiPageName(title) + filename := path.Join(localPath, title+".md") + + // If not a new file, show perform update not create. + if isNew { + if com.IsExist(filename) { + return ErrWikiAlreadyExist{filename} + } + } else { + os.Remove(path.Join(localPath, oldTitle+".md")) + } + + // SECURITY: if new file is a symlink to non-exist critical file, + // attack content can be written to the target file (e.g. authorized_keys2) + // as a new page operation. + // So we want to make sure the symlink is removed before write anything. + // The new file we created will be in normal text format. + os.Remove(filename) + + if err = ioutil.WriteFile(filename, []byte(content), 0666); err != nil { + return fmt.Errorf("WriteFile: %v", err) + } + + if len(message) == 0 { + message = "Update page '" + title + "'" + } + if err = git.AddChanges(localPath, true); err != nil { + return fmt.Errorf("AddChanges: %v", err) + } else if err = git.CommitChanges(localPath, git.CommitChangesOptions{ + Committer: doer.NewGitSig(), + Message: message, + }); err != nil { + return fmt.Errorf("CommitChanges: %v", err) + } else if err = git.Push(localPath, "origin", "master"); err != nil { + return fmt.Errorf("Push: %v", err) + } + + return nil +} + +func (repo *Repository) AddWikiPage(doer *User, title, content, message string) error { + return repo.updateWikiPage(doer, "", title, content, message, true) +} + +func (repo *Repository) EditWikiPage(doer *User, oldTitle, title, content, message string) error { + return repo.updateWikiPage(doer, oldTitle, title, content, message, false) +} + +func (repo *Repository) DeleteWikiPage(doer *User, title string) (err error) { + wikiWorkingPool.CheckIn(com.ToStr(repo.ID)) + defer wikiWorkingPool.CheckOut(com.ToStr(repo.ID)) + + localPath := repo.LocalWikiPath() + if err = discardLocalWikiChanges(localPath); err != nil { + return fmt.Errorf("discardLocalWikiChanges: %v", err) + } else if err = repo.UpdateLocalWiki(); err != nil { + return fmt.Errorf("UpdateLocalWiki: %v", err) + } + + title = ToWikiPageName(title) + filename := path.Join(localPath, title+".md") + os.Remove(filename) + + message := "Delete page '" + title + "'" + + if err = git.AddChanges(localPath, true); err != nil { + return fmt.Errorf("AddChanges: %v", err) + } else if err = git.CommitChanges(localPath, git.CommitChangesOptions{ + Committer: doer.NewGitSig(), + Message: message, + }); err != nil { + return fmt.Errorf("CommitChanges: %v", err) + } else if err = git.Push(localPath, "origin", "master"); err != nil { + return fmt.Errorf("Push: %v", err) + } + + return nil +} |