diff --git a/cmd/admin.go b/cmd/admin.go index 5492b9a2db..6c79141eab 100644 --- a/cmd/admin.go +++ b/cmd/admin.go @@ -25,6 +25,7 @@ var ( subcmdCreateUser, subcmdChangePassword, subcmdRepoSyncReleases, + subcmdRegenerate, }, } @@ -80,6 +81,41 @@ var ( Usage: "Synchronize repository releases with tags", Action: runRepoSyncReleases, } + + subcmdRegenerate = cli.Command{ + Name: "regenerate", + Usage: "Regenerate specific files", + Subcommands: []cli.Command{ + microcmdRegenHooks, + microcmdRegenKeys, + }, + } + + microcmdRegenHooks = cli.Command{ + Name: "hooks", + Usage: "Regenerate git-hooks", + Action: runRegenerateHooks, + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "config, c", + Value: "custom/conf/app.ini", + Usage: "Custom configuration file path", + }, + }, + } + + microcmdRegenKeys = cli.Command{ + Name: "keys", + Usage: "Regenerate authorized_keys file", + Action: runRegenerateKeys, + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "config, c", + Value: "custom/conf/app.ini", + Usage: "Custom configuration file path", + }, + }, + } ) func runChangePassword(c *cli.Context) error { @@ -195,3 +231,25 @@ func getReleaseCount(id int64) (int64, error) { }, ) } + +func runRegenerateHooks(c *cli.Context) error { + if c.IsSet("config") { + setting.CustomConf = c.String("config") + } + + if err := initDB(); err != nil { + return err + } + return models.SyncRepositoryHooks() +} + +func runRegenerateKeys(c *cli.Context) error { + if c.IsSet("config") { + setting.CustomConf = c.String("config") + } + + if err := initDB(); err != nil { + return err + } + return models.RewriteAllPublicKeys() +} diff --git a/docker/etc/s6/gitea/run b/docker/etc/s6/gitea/run index 1fddb93708..da5fd6b535 100755 --- a/docker/etc/s6/gitea/run +++ b/docker/etc/s6/gitea/run @@ -2,5 +2,5 @@ [[ -f ./setup ]] && source ./setup pushd /app/gitea > /dev/null - exec su-exec git /app/gitea/gitea web + exec su-exec $USER /app/gitea/gitea web popd diff --git a/docker/etc/s6/gitea/setup b/docker/etc/s6/gitea/setup index 8e6441c5c2..6ca9b82123 100755 --- a/docker/etc/s6/gitea/setup +++ b/docker/etc/s6/gitea/setup @@ -39,5 +39,5 @@ if [ ! -f /data/gitea/conf/app.ini ]; then envsubst < /etc/templates/app.ini > /data/gitea/conf/app.ini fi -chown -R git:git /data/gitea /app/gitea /data/git +chown -R ${USER}:git /data/gitea /app/gitea /data/git chmod 0755 /data/gitea /app/gitea /data/git diff --git a/docker/usr/bin/entrypoint b/docker/usr/bin/entrypoint index b374c5aed7..50623bfa66 100755 --- a/docker/usr/bin/entrypoint +++ b/docker/usr/bin/entrypoint @@ -1,5 +1,12 @@ #!/bin/sh +if [ "${USER}" != "git" ]; then + # rename user + sed -i -e "s/^git\:/${USER}\:/g" /etc/passwd + # switch sshd config to different user + sed -i -e "s/AllowUsers git/AllowUsers ${USER}/g" /etc/ssh/sshd_config +fi + ## Change GID for USER? if [ -n "${USER_GID}" ] && [ "${USER_GID}" != "`id -g ${USER}`" ]; then sed -i -e "s/^${USER}:\([^:]*\):[0-9]*/${USER}:\1:${USER_GID}/" /etc/group diff --git a/docs/content/doc/features/comparison.en-us.md b/docs/content/doc/features/comparison.en-us.md index c2b7b037c3..db0d3b5553 100644 --- a/docs/content/doc/features/comparison.en-us.md +++ b/docs/content/doc/features/comparison.en-us.md @@ -537,7 +537,7 @@ _Symbols used in table:_ Webhook support - ⁄ + ✓ ✓ ✓ ✓ diff --git a/docs/content/doc/usage/command-line.md b/docs/content/doc/usage/command-line.md index cf6feeaf5e..9c16d49049 100644 --- a/docs/content/doc/usage/command-line.md +++ b/docs/content/doc/usage/command-line.md @@ -64,6 +64,13 @@ Admin operations: - `--password value`, `-p value`: New password. Required. - Examples: - `gitea admin change-password --username myname --password asecurepassword` + - `regenerate` + - Options: + - `hooks`: Regenerate git-hooks for all repositories + - `keys`: Regenerate authorized_keys file + - Examples: + - `gitea admin regenerate hooks` + - `gitea admin regenerate keys` #### cert diff --git a/models/action.go b/models/action.go index 4f357cb2c5..c3ed9c7c02 100644 --- a/models/action.go +++ b/models/action.go @@ -618,6 +618,16 @@ func CommitRepoAction(opts CommitRepoActionOptions) error { case ActionDeleteBranch: // Delete Branch isHookEventPush = true + if err = PrepareWebhooks(repo, HookEventDelete, &api.DeletePayload{ + Ref: refName, + RefType: "branch", + PusherType: api.PusherTypeUser, + Repo: apiRepo, + Sender: apiPusher, + }); err != nil { + return fmt.Errorf("PrepareWebhooks.(delete branch): %v", err) + } + case ActionPushTag: // Create isHookEventPush = true @@ -640,6 +650,16 @@ func CommitRepoAction(opts CommitRepoActionOptions) error { } case ActionDeleteTag: // Delete Tag isHookEventPush = true + + if err = PrepareWebhooks(repo, HookEventDelete, &api.DeletePayload{ + Ref: refName, + RefType: "tag", + PusherType: api.PusherTypeUser, + Repo: apiRepo, + Sender: apiPusher, + }); err != nil { + return fmt.Errorf("PrepareWebhooks.(delete tag): %v", err) + } } if isHookEventPush { diff --git a/models/issue_comment.go b/models/issue_comment.go index 6514223711..bb48581b2b 100644 --- a/models/issue_comment.go +++ b/models/issue_comment.go @@ -89,9 +89,10 @@ const ( type Comment struct { ID int64 `xorm:"pk autoincr"` Type CommentType - PosterID int64 `xorm:"INDEX"` - Poster *User `xorm:"-"` - IssueID int64 `xorm:"INDEX"` + PosterID int64 `xorm:"INDEX"` + Poster *User `xorm:"-"` + IssueID int64 `xorm:"INDEX"` + Issue *Issue `xorm:"-"` LabelID int64 Label *Label `xorm:"-"` OldMilestoneID int64 @@ -127,6 +128,15 @@ type Comment struct { Invalidated bool } +// LoadIssue loads issue from database +func (c *Comment) LoadIssue() (err error) { + if c.Issue != nil { + return nil + } + c.Issue, err = GetIssueByID(c.IssueID) + return +} + // AfterLoad is invoked from XORM after setting the values of all fields of this object. func (c *Comment) AfterLoad(session *xorm.Session) { var err error @@ -157,40 +167,40 @@ func (c *Comment) AfterDelete() { // HTMLURL formats a URL-string to the issue-comment func (c *Comment) HTMLURL() string { - issue, err := GetIssueByID(c.IssueID) + err := c.LoadIssue() if err != nil { // Silently dropping errors :unamused: - log.Error(4, "GetIssueByID(%d): %v", c.IssueID, err) + log.Error(4, "LoadIssue(%d): %v", c.IssueID, err) return "" } - return fmt.Sprintf("%s#%s", issue.HTMLURL(), c.HashTag()) + return fmt.Sprintf("%s#%s", c.Issue.HTMLURL(), c.HashTag()) } // IssueURL formats a URL-string to the issue func (c *Comment) IssueURL() string { - issue, err := GetIssueByID(c.IssueID) + err := c.LoadIssue() if err != nil { // Silently dropping errors :unamused: - log.Error(4, "GetIssueByID(%d): %v", c.IssueID, err) + log.Error(4, "LoadIssue(%d): %v", c.IssueID, err) return "" } - if issue.IsPull { + if c.Issue.IsPull { return "" } - return issue.HTMLURL() + return c.Issue.HTMLURL() } // PRURL formats a URL-string to the pull-request func (c *Comment) PRURL() string { - issue, err := GetIssueByID(c.IssueID) + err := c.LoadIssue() if err != nil { // Silently dropping errors :unamused: - log.Error(4, "GetIssueByID(%d): %v", c.IssueID, err) + log.Error(4, "LoadIssue(%d): %v", c.IssueID, err) return "" } - if !issue.IsPull { + if !c.Issue.IsPull { return "" } - return issue.HTMLURL() + return c.Issue.HTMLURL() } // APIFormat converts a Comment to the api.Comment format @@ -207,9 +217,14 @@ func (c *Comment) APIFormat() *api.Comment { } } +// CommentHashTag returns unique hash tag for comment id. +func CommentHashTag(id int64) string { + return fmt.Sprintf("issuecomment-%d", id) +} + // HashTag returns unique hash tag for comment. func (c *Comment) HashTag() string { - return "issuecomment-" + com.ToStr(c.ID) + return CommentHashTag(c.ID) } // EventTag returns unique event hash tag for comment. @@ -638,7 +653,7 @@ func CreateComment(opts *CreateCommentOptions) (comment *Comment, err error) { // CreateIssueComment creates a plain issue comment. func CreateIssueComment(doer *User, repo *Repository, issue *Issue, content string, attachments []string) (*Comment, error) { - return CreateComment(&CreateCommentOptions{ + comment, err := CreateComment(&CreateCommentOptions{ Type: CommentTypeComment, Doer: doer, Repo: repo, @@ -646,6 +661,21 @@ func CreateIssueComment(doer *User, repo *Repository, issue *Issue, content stri Content: content, Attachments: attachments, }) + if err != nil { + return nil, fmt.Errorf("CreateComment: %v", err) + } + + mode, _ := AccessLevel(doer.ID, repo) + if err = PrepareWebhooks(repo, HookEventIssueComment, &api.IssueCommentPayload{ + Action: api.HookIssueCommentCreated, + Issue: issue.APIFormat(), + Comment: comment.APIFormat(), + Repository: repo.APIFormat(mode), + Sender: doer.APIFormat(), + }); err != nil { + log.Error(2, "PrepareWebhooks [comment_id: %d]: %v", comment.ID, err) + } + return comment, nil } // CreateCodeComment creates a plain code comment at the specified line / path @@ -798,17 +828,41 @@ func GetCommentsByRepoIDSince(repoID, since int64) ([]*Comment, error) { } // UpdateComment updates information of comment. -func UpdateComment(c *Comment) error { +func UpdateComment(doer *User, c *Comment, oldContent string) error { if _, err := x.ID(c.ID).AllCols().Update(c); err != nil { return err } else if c.Type == CommentTypeComment { UpdateIssueIndexer(c.IssueID) } + + if err := c.LoadIssue(); err != nil { + return err + } + if err := c.Issue.LoadAttributes(); err != nil { + return err + } + + mode, _ := AccessLevel(doer.ID, c.Issue.Repo) + if err := PrepareWebhooks(c.Issue.Repo, HookEventIssueComment, &api.IssueCommentPayload{ + Action: api.HookIssueCommentEdited, + Issue: c.Issue.APIFormat(), + Comment: c.APIFormat(), + Changes: &api.ChangesPayload{ + Body: &api.ChangesFromPayload{ + From: oldContent, + }, + }, + Repository: c.Issue.Repo.APIFormat(mode), + Sender: doer.APIFormat(), + }); err != nil { + log.Error(2, "PrepareWebhooks [comment_id: %d]: %v", c.ID, err) + } + return nil } // DeleteComment deletes the comment -func DeleteComment(comment *Comment) error { +func DeleteComment(doer *User, comment *Comment) error { sess := x.NewSession() defer sess.Close() if err := sess.Begin(); err != nil { @@ -835,6 +889,26 @@ func DeleteComment(comment *Comment) error { } else if comment.Type == CommentTypeComment { UpdateIssueIndexer(comment.IssueID) } + + if err := comment.LoadIssue(); err != nil { + return err + } + if err := comment.Issue.LoadAttributes(); err != nil { + return err + } + + mode, _ := AccessLevel(doer.ID, comment.Issue.Repo) + + if err := PrepareWebhooks(comment.Issue.Repo, HookEventIssueComment, &api.IssueCommentPayload{ + Action: api.HookIssueCommentDeleted, + Issue: comment.Issue.APIFormat(), + Comment: comment.APIFormat(), + Repository: comment.Issue.Repo.APIFormat(mode), + Sender: doer.APIFormat(), + }); err != nil { + log.Error(2, "PrepareWebhooks [comment_id: %d]: %v", comment.ID, err) + } + return nil } diff --git a/models/issue_milestone.go b/models/issue_milestone.go index 8de1f97571..be55dc4f5b 100644 --- a/models/issue_milestone.go +++ b/models/issue_milestone.go @@ -5,6 +5,9 @@ package models import ( + "fmt" + + "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" api "code.gitea.io/sdk/gitea" @@ -358,7 +361,49 @@ func ChangeMilestoneAssign(issue *Issue, doer *User, oldMilestoneID int64) (err if err = changeMilestoneAssign(sess, doer, issue, oldMilestoneID); err != nil { return err } - return sess.Commit() + + if err = sess.Commit(); err != nil { + return fmt.Errorf("Commit: %v", err) + } + + var hookAction api.HookIssueAction + if issue.MilestoneID > 0 { + hookAction = api.HookIssueMilestoned + } else { + hookAction = api.HookIssueDemilestoned + } + + if err = issue.LoadAttributes(); err != nil { + return err + } + + mode, _ := AccessLevel(doer.ID, issue.Repo) + if issue.IsPull { + err = issue.PullRequest.LoadIssue() + if err != nil { + log.Error(2, "LoadIssue: %v", err) + return + } + err = PrepareWebhooks(issue.Repo, HookEventPullRequest, &api.PullRequestPayload{ + Action: hookAction, + Index: issue.Index, + PullRequest: issue.PullRequest.APIFormat(), + Repository: issue.Repo.APIFormat(mode), + Sender: doer.APIFormat(), + }) + } else { + err = PrepareWebhooks(issue.Repo, HookEventIssues, &api.IssuePayload{ + Action: hookAction, + Index: issue.Index, + Issue: issue.APIFormat(), + Repository: issue.Repo.APIFormat(mode), + Sender: doer.APIFormat(), + }) + } + if err != nil { + log.Error(2, "PrepareWebhooks [is_pull: %v]: %v", issue.IsPull, err) + } + return nil } // DeleteMilestoneByRepoID deletes a milestone from a repository. diff --git a/models/issue_milestone_test.go b/models/issue_milestone_test.go index c9b53f4f4a..3ea63d2d6b 100644 --- a/models/issue_milestone_test.go +++ b/models/issue_milestone_test.go @@ -232,6 +232,8 @@ func TestChangeMilestoneAssign(t *testing.T) { assert.NoError(t, PrepareTestDatabase()) issue := AssertExistsAndLoadBean(t, &Issue{RepoID: 1}).(*Issue) doer := AssertExistsAndLoadBean(t, &User{ID: 2}).(*User) + assert.NotNil(t, issue) + assert.NotNil(t, doer) oldMilestoneID := issue.MilestoneID issue.MilestoneID = 2 diff --git a/models/release.go b/models/release.go index 586f494e7d..bc0260c71d 100644 --- a/models/release.go +++ b/models/release.go @@ -10,6 +10,7 @@ import ( "strings" "code.gitea.io/git" + "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/process" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" @@ -190,8 +191,27 @@ func CreateRelease(gitRepo *git.Repository, rel *Release, attachmentUUIDs []stri } err = addReleaseAttachments(rel.ID, attachmentUUIDs) + if err != nil { + return err + } - return err + if !rel.IsDraft { + if err := rel.LoadAttributes(); err != nil { + log.Error(2, "LoadAttributes: %v", err) + } else { + mode, _ := AccessLevel(rel.PublisherID, rel.Repo) + if err := PrepareWebhooks(rel.Repo, HookEventRelease, &api.ReleasePayload{ + Action: api.HookReleasePublished, + Release: rel.APIFormat(), + Repository: rel.Repo.APIFormat(mode), + Sender: rel.Publisher.APIFormat(), + }); err != nil { + log.Error(2, "PrepareWebhooks: %v", err) + } + } + } + + return nil } // GetRelease returns release by given ID. diff --git a/models/repo.go b/models/repo.go index 4a7eb859c4..f5ec1a9fdd 100644 --- a/models/repo.go +++ b/models/repo.go @@ -2456,6 +2456,17 @@ func ForkRepository(doer, u *User, oldRepo *Repository, name, desc string) (_ *R return nil, err } + oldMode, _ := AccessLevel(doer.ID, oldRepo) + mode, _ := AccessLevel(doer.ID, repo) + + if err = PrepareWebhooks(oldRepo, HookEventFork, &api.ForkPayload{ + Forkee: repo.APIFormat(mode), + Repo: oldRepo.APIFormat(oldMode), + Sender: doer.APIFormat(), + }); err != nil { + log.Error(2, "PrepareWebhooks [repo_id: %d]: %v", oldRepo.ID, err) + } + if err = repo.UpdateSize(); err != nil { log.Error(4, "Failed to update size for repository: %v", err) } diff --git a/models/webhook.go b/models/webhook.go index 62db84f86a..c44ca2960d 100644 --- a/models/webhook.go +++ b/models/webhook.go @@ -66,10 +66,15 @@ func IsValidHookContentType(name string) bool { // HookEvents is a set of web hook events type HookEvents struct { - Create bool `json:"create"` - Push bool `json:"push"` - PullRequest bool `json:"pull_request"` - Repository bool `json:"repository"` + Create bool `json:"create"` + Delete bool `json:"delete"` + Fork bool `json:"fork"` + Issues bool `json:"issues"` + IssueComment bool `json:"issue_comment"` + Push bool `json:"push"` + PullRequest bool `json:"pull_request"` + Repository bool `json:"repository"` + Release bool `json:"release"` } // HookEvent represents events that will delivery hook. @@ -155,6 +160,30 @@ func (w *Webhook) HasCreateEvent() bool { (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) +} + +// HasIssuesEvent returns true if hook enabled issues event. +func (w *Webhook) HasIssuesEvent() bool { + return w.SendEverything || + (w.ChooseEvents && w.HookEvents.Issues) +} + +// HasIssueCommentEvent returns true if hook enabled issue_comment event. +func (w *Webhook) HasIssueCommentEvent() bool { + return w.SendEverything || + (w.ChooseEvents && w.HookEvents.IssueComment) +} + // HasPushEvent returns true if hook enabled push event. func (w *Webhook) HasPushEvent() bool { return w.PushOnly || w.SendEverything || @@ -167,23 +196,46 @@ func (w *Webhook) HasPullRequestEvent() bool { (w.ChooseEvents && w.HookEvents.PullRequest) } +// HasReleaseEvent returns if hook enabled release event. +func (w *Webhook) HasReleaseEvent() bool { + return w.SendEverything || + (w.ChooseEvents && w.HookEvents.Release) +} + // HasRepositoryEvent returns if hook enabled repository event. func (w *Webhook) HasRepositoryEvent() bool { return w.SendEverything || (w.ChooseEvents && w.HookEvents.Repository) } +func (w *Webhook) eventCheckers() []struct { + has func() bool + typ HookEventType +} { + return []struct { + has func() bool + typ HookEventType + }{ + {w.HasCreateEvent, HookEventCreate}, + {w.HasDeleteEvent, HookEventDelete}, + {w.HasForkEvent, HookEventFork}, + {w.HasPushEvent, HookEventPush}, + {w.HasIssuesEvent, HookEventIssues}, + {w.HasIssueCommentEvent, HookEventIssueComment}, + {w.HasPullRequestEvent, HookEventPullRequest}, + {w.HasRepositoryEvent, HookEventRepository}, + {w.HasReleaseEvent, HookEventRelease}, + } +} + // EventsArray returns an array of hook events func (w *Webhook) EventsArray() []string { - events := make([]string, 0, 3) - if w.HasCreateEvent() { - events = append(events, "create") - } - if w.HasPushEvent() { - events = append(events, "push") - } - if w.HasPullRequestEvent() { - events = append(events, "pull_request") + events := make([]string, 0, 7) + + for _, c := range w.eventCheckers() { + if c.has() { + events = append(events, string(c.typ)) + } } return events } @@ -373,10 +425,15 @@ type HookEventType string // Types of hook events const ( - HookEventCreate HookEventType = "create" - HookEventPush HookEventType = "push" - HookEventPullRequest HookEventType = "pull_request" - HookEventRepository HookEventType = "repository" + HookEventCreate HookEventType = "create" + HookEventDelete HookEventType = "delete" + HookEventFork HookEventType = "fork" + HookEventPush HookEventType = "push" + HookEventIssues HookEventType = "issues" + HookEventIssueComment HookEventType = "issue_comment" + HookEventPullRequest HookEventType = "pull_request" + HookEventRepository HookEventType = "repository" + HookEventRelease HookEventType = "release" ) // HookRequest represents hook task request information. @@ -488,22 +545,11 @@ func PrepareWebhook(w *Webhook, repo *Repository, event HookEventType, p api.Pay } func prepareWebhook(e Engine, w *Webhook, repo *Repository, event HookEventType, p api.Payloader) error { - switch event { - case HookEventCreate: - if !w.HasCreateEvent() { - return nil - } - case HookEventPush: - if !w.HasPushEvent() { - return nil - } - case HookEventPullRequest: - if !w.HasPullRequestEvent() { - return nil - } - case HookEventRepository: - if !w.HasRepositoryEvent() { - return nil + for _, e := range w.eventCheckers() { + if event == e.typ { + if !e.has() { + return nil + } } } diff --git a/models/webhook_dingtalk.go b/models/webhook_dingtalk.go index 719ffcae73..7eb189f9bb 100644 --- a/models/webhook_dingtalk.go +++ b/models/webhook_dingtalk.go @@ -49,6 +49,38 @@ func getDingtalkCreatePayload(p *api.CreatePayload) (*DingtalkPayload, error) { }, nil } +func getDingtalkDeletePayload(p *api.DeletePayload) (*DingtalkPayload, error) { + // created tag/branch + refName := git.RefEndName(p.Ref) + title := fmt.Sprintf("[%s] %s %s deleted", p.Repo.FullName, p.RefType, refName) + + return &DingtalkPayload{ + MsgType: "actionCard", + ActionCard: dingtalk.ActionCard{ + Text: title, + Title: title, + HideAvatar: "0", + SingleTitle: fmt.Sprintf("view branch %s", refName), + SingleURL: p.Repo.HTMLURL + "/src/" + refName, + }, + }, nil +} + +func getDingtalkForkPayload(p *api.ForkPayload) (*DingtalkPayload, error) { + title := fmt.Sprintf("%s is forked to %s", p.Forkee.FullName, p.Repo.FullName) + + return &DingtalkPayload{ + MsgType: "actionCard", + ActionCard: dingtalk.ActionCard{ + Text: title, + Title: title, + HideAvatar: "0", + SingleTitle: fmt.Sprintf("view forked repo %s", p.Repo.FullName), + SingleURL: p.Repo.HTMLURL, + }, + }, nil +} + func getDingtalkPushPayload(p *api.PushPayload) (*DingtalkPayload, error) { var ( branchName = git.RefEndName(p.Ref) @@ -98,6 +130,80 @@ func getDingtalkPushPayload(p *api.PushPayload) (*DingtalkPayload, error) { }, nil } +func getDingtalkIssuesPayload(p *api.IssuePayload) (*DingtalkPayload, error) { + var text, title string + switch p.Action { + case api.HookIssueOpened: + title = fmt.Sprintf("[%s] Issue opened: #%d %s", p.Repository.FullName, p.Index, p.Issue.Title) + text = p.Issue.Body + case api.HookIssueClosed: + title = fmt.Sprintf("[%s] Issue closed: #%d %s", p.Repository.FullName, p.Index, p.Issue.Title) + text = p.Issue.Body + case api.HookIssueReOpened: + title = fmt.Sprintf("[%s] Issue re-opened: #%d %s", p.Repository.FullName, p.Index, p.Issue.Title) + text = p.Issue.Body + case api.HookIssueEdited: + title = fmt.Sprintf("[%s] Issue edited: #%d %s", p.Repository.FullName, p.Index, p.Issue.Title) + text = p.Issue.Body + case api.HookIssueAssigned: + title = fmt.Sprintf("[%s] Issue assigned to %s: #%d %s", p.Repository.FullName, + p.Issue.Assignee.UserName, p.Index, p.Issue.Title) + text = p.Issue.Body + case api.HookIssueUnassigned: + title = fmt.Sprintf("[%s] Issue unassigned: #%d %s", p.Repository.FullName, p.Index, p.Issue.Title) + text = p.Issue.Body + case api.HookIssueLabelUpdated: + title = fmt.Sprintf("[%s] Pull request labels updated: #%d %s", p.Repository.FullName, p.Index, p.Issue.Title) + text = p.Issue.Body + case api.HookIssueLabelCleared: + title = fmt.Sprintf("[%s] Pull request labels cleared: #%d %s", p.Repository.FullName, p.Index, p.Issue.Title) + text = p.Issue.Body + case api.HookIssueSynchronized: + title = fmt.Sprintf("[%s] Pull request synchronized: #%d %s", p.Repository.FullName, p.Index, p.Issue.Title) + text = p.Issue.Body + } + + return &DingtalkPayload{ + MsgType: "actionCard", + ActionCard: dingtalk.ActionCard{ + Text: text, + Title: title, + HideAvatar: "0", + SingleTitle: "view pull request", + SingleURL: p.Issue.URL, + }, + }, nil +} + +func getDingtalkIssueCommentPayload(p *api.IssueCommentPayload) (*DingtalkPayload, 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)) + var content string + switch p.Action { + case api.HookIssueCommentCreated: + title = "New comment: " + title + content = p.Comment.Body + case api.HookIssueCommentEdited: + title = "Comment edited: " + title + content = p.Comment.Body + case api.HookIssueCommentDeleted: + title = "Comment deleted: " + title + url = fmt.Sprintf("%s/issues/%d", p.Repository.HTMLURL, p.Issue.Index) + content = p.Comment.Body + } + + return &DingtalkPayload{ + MsgType: "actionCard", + ActionCard: dingtalk.ActionCard{ + Text: content, + Title: title, + HideAvatar: "0", + SingleTitle: "view pull request", + SingleURL: url, + }, + }, nil +} + func getDingtalkPullRequestPayload(p *api.PullRequestPayload) (*DingtalkPayload, error) { var text, title string switch p.Action { @@ -182,6 +288,27 @@ func getDingtalkRepositoryPayload(p *api.RepositoryPayload) (*DingtalkPayload, e return nil, nil } +func getDingtalkReleasePayload(p *api.ReleasePayload) (*DingtalkPayload, error) { + var title, url string + switch p.Action { + case api.HookReleasePublished: + title = fmt.Sprintf("[%s] Release created", p.Release.TagName) + url = p.Release.URL + return &DingtalkPayload{ + MsgType: "actionCard", + ActionCard: dingtalk.ActionCard{ + Text: title, + Title: title, + HideAvatar: "0", + SingleTitle: "view repository", + SingleURL: url, + }, + }, nil + } + + return nil, nil +} + // GetDingtalkPayload converts a ding talk webhook into a DingtalkPayload func GetDingtalkPayload(p api.Payloader, event HookEventType, meta string) (*DingtalkPayload, error) { s := new(DingtalkPayload) @@ -189,12 +316,22 @@ func GetDingtalkPayload(p api.Payloader, event HookEventType, meta string) (*Din switch event { case HookEventCreate: return getDingtalkCreatePayload(p.(*api.CreatePayload)) + case HookEventDelete: + return getDingtalkDeletePayload(p.(*api.DeletePayload)) + case HookEventFork: + return getDingtalkForkPayload(p.(*api.ForkPayload)) + case HookEventIssues: + return getDingtalkIssuesPayload(p.(*api.IssuePayload)) + case HookEventIssueComment: + return getDingtalkIssueCommentPayload(p.(*api.IssueCommentPayload)) case HookEventPush: return getDingtalkPushPayload(p.(*api.PushPayload)) case HookEventPullRequest: return getDingtalkPullRequestPayload(p.(*api.PullRequestPayload)) case HookEventRepository: return getDingtalkRepositoryPayload(p.(*api.RepositoryPayload)) + case HookEventRelease: + return getDingtalkReleasePayload(p.(*api.ReleasePayload)) } return s, nil diff --git a/models/webhook_discord.go b/models/webhook_discord.go index 40d9d58992..04ebbc293f 100644 --- a/models/webhook_discord.go +++ b/models/webhook_discord.go @@ -115,6 +115,51 @@ func getDiscordCreatePayload(p *api.CreatePayload, meta *DiscordMeta) (*DiscordP }, nil } +func getDiscordDeletePayload(p *api.DeletePayload, meta *DiscordMeta) (*DiscordPayload, error) { + // deleted tag/branch + refName := git.RefEndName(p.Ref) + title := fmt.Sprintf("[%s] %s %s deleted", p.Repo.FullName, p.RefType, refName) + + return &DiscordPayload{ + Username: meta.Username, + AvatarURL: meta.IconURL, + Embeds: []DiscordEmbed{ + { + Title: title, + URL: p.Repo.HTMLURL + "/src/" + refName, + Color: warnColor, + Author: DiscordEmbedAuthor{ + Name: p.Sender.UserName, + URL: setting.AppURL + p.Sender.UserName, + IconURL: p.Sender.AvatarURL, + }, + }, + }, + }, nil +} + +func getDiscordForkPayload(p *api.ForkPayload, meta *DiscordMeta) (*DiscordPayload, error) { + // fork + title := fmt.Sprintf("%s is forked to %s", p.Forkee.FullName, p.Repo.FullName) + + return &DiscordPayload{ + Username: meta.Username, + AvatarURL: meta.IconURL, + Embeds: []DiscordEmbed{ + { + Title: title, + URL: p.Repo.HTMLURL, + Color: successColor, + Author: DiscordEmbedAuthor{ + Name: p.Sender.UserName, + URL: setting.AppURL + p.Sender.UserName, + IconURL: p.Sender.AvatarURL, + }, + }, + }, + }, nil +} + func getDiscordPushPayload(p *api.PushPayload, meta *DiscordMeta) (*DiscordPayload, error) { var ( branchName = git.RefEndName(p.Ref) @@ -165,6 +210,108 @@ func getDiscordPushPayload(p *api.PushPayload, meta *DiscordMeta) (*DiscordPaylo }, nil } +func getDiscordIssuesPayload(p *api.IssuePayload, meta *DiscordMeta) (*DiscordPayload, error) { + var text, title string + var color int + switch p.Action { + case api.HookIssueOpened: + title = fmt.Sprintf("[%s] Issue opened: #%d %s", p.Repository.FullName, p.Index, p.Issue.Title) + text = p.Issue.Body + color = warnColor + case api.HookIssueClosed: + title = fmt.Sprintf("[%s] Issue closed: #%d %s", p.Repository.FullName, p.Index, p.Issue.Title) + color = failedColor + text = p.Issue.Body + case api.HookIssueReOpened: + title = fmt.Sprintf("[%s] Issue re-opened: #%d %s", p.Repository.FullName, p.Index, p.Issue.Title) + text = p.Issue.Body + color = warnColor + case api.HookIssueEdited: + title = fmt.Sprintf("[%s] Issue edited: #%d %s", p.Repository.FullName, p.Index, p.Issue.Title) + text = p.Issue.Body + color = warnColor + case api.HookIssueAssigned: + title = fmt.Sprintf("[%s] Issue assigned to %s: #%d %s", p.Repository.FullName, + p.Issue.Assignee.UserName, p.Index, p.Issue.Title) + text = p.Issue.Body + color = successColor + case api.HookIssueUnassigned: + title = fmt.Sprintf("[%s] Issue unassigned: #%d %s", p.Repository.FullName, p.Index, p.Issue.Title) + text = p.Issue.Body + color = warnColor + case api.HookIssueLabelUpdated: + title = fmt.Sprintf("[%s] Issue labels updated: #%d %s", p.Repository.FullName, p.Index, p.Issue.Title) + text = p.Issue.Body + color = warnColor + case api.HookIssueLabelCleared: + title = fmt.Sprintf("[%s] Issue labels cleared: #%d %s", p.Repository.FullName, p.Index, p.Issue.Title) + text = p.Issue.Body + color = warnColor + case api.HookIssueSynchronized: + title = fmt.Sprintf("[%s] Issue synchronized: #%d %s", p.Repository.FullName, p.Index, p.Issue.Title) + text = p.Issue.Body + color = warnColor + } + + return &DiscordPayload{ + Username: meta.Username, + AvatarURL: meta.IconURL, + Embeds: []DiscordEmbed{ + { + Title: title, + Description: text, + URL: p.Issue.URL, + Color: color, + Author: DiscordEmbedAuthor{ + Name: p.Sender.UserName, + URL: setting.AppURL + p.Sender.UserName, + IconURL: p.Sender.AvatarURL, + }, + }, + }, + }, nil +} + +func getDiscordIssueCommentPayload(p *api.IssueCommentPayload, discord *DiscordMeta) (*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 := "" + var color int + switch p.Action { + case api.HookIssueCommentCreated: + title = "New comment: " + title + content = p.Comment.Body + color = successColor + case api.HookIssueCommentEdited: + title = "Comment edited: " + title + content = p.Comment.Body + color = warnColor + case api.HookIssueCommentDeleted: + title = "Comment deleted: " + title + url = fmt.Sprintf("%s/issues/%d", p.Repository.HTMLURL, p.Issue.Index) + content = p.Comment.Body + color = warnColor + } + + return &DiscordPayload{ + Username: discord.Username, + AvatarURL: discord.IconURL, + Embeds: []DiscordEmbed{ + { + Title: title, + Description: content, + URL: url, + Color: color, + Author: DiscordEmbedAuthor{ + Name: p.Sender.UserName, + URL: setting.AppURL + p.Sender.UserName, + IconURL: p.Sender.AvatarURL, + }, + }, + }, + }, nil +} + func getDiscordPullRequestPayload(p *api.PullRequestPayload, meta *DiscordMeta) (*DiscordPayload, error) { var text, title string var color int @@ -267,6 +414,35 @@ func getDiscordRepositoryPayload(p *api.RepositoryPayload, meta *DiscordMeta) (* }, nil } +func getDiscordReleasePayload(p *api.ReleasePayload, meta *DiscordMeta) (*DiscordPayload, error) { + var title, url string + var color int + switch p.Action { + case api.HookReleasePublished: + title = fmt.Sprintf("[%s] Release created", p.Release.TagName) + url = p.Release.URL + color = successColor + } + + return &DiscordPayload{ + Username: meta.Username, + AvatarURL: meta.IconURL, + Embeds: []DiscordEmbed{ + { + Title: title, + Description: fmt.Sprintf("%s", p.Release.Note), + URL: url, + Color: color, + Author: DiscordEmbedAuthor{ + Name: p.Sender.UserName, + URL: setting.AppURL + p.Sender.UserName, + IconURL: p.Sender.AvatarURL, + }, + }, + }, + }, nil +} + // GetDiscordPayload converts a discord webhook into a DiscordPayload func GetDiscordPayload(p api.Payloader, event HookEventType, meta string) (*DiscordPayload, error) { s := new(DiscordPayload) @@ -279,12 +455,22 @@ func GetDiscordPayload(p api.Payloader, event HookEventType, meta string) (*Disc switch event { case HookEventCreate: return getDiscordCreatePayload(p.(*api.CreatePayload), discord) + case HookEventDelete: + return getDiscordDeletePayload(p.(*api.DeletePayload), discord) + case HookEventFork: + return getDiscordForkPayload(p.(*api.ForkPayload), discord) + case HookEventIssues: + return getDiscordIssuesPayload(p.(*api.IssuePayload), discord) + case HookEventIssueComment: + return getDiscordIssueCommentPayload(p.(*api.IssueCommentPayload), discord) case HookEventPush: return getDiscordPushPayload(p.(*api.PushPayload), discord) case HookEventPullRequest: return getDiscordPullRequestPayload(p.(*api.PullRequestPayload), discord) case HookEventRepository: return getDiscordRepositoryPayload(p.(*api.RepositoryPayload), discord) + case HookEventRelease: + return getDiscordReleasePayload(p.(*api.ReleasePayload), discord) } return s, nil diff --git a/models/webhook_slack.go b/models/webhook_slack.go index 256819adc5..7b18fe3278 100644 --- a/models/webhook_slack.go +++ b/models/webhook_slack.go @@ -106,6 +106,122 @@ func getSlackCreatePayload(p *api.CreatePayload, slack *SlackMeta) (*SlackPayloa }, nil } +// getSlackDeletePayload composes Slack payload for delete a branch or tag. +func getSlackDeletePayload(p *api.DeletePayload, slack *SlackMeta) (*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{ + Channel: slack.Channel, + Text: text, + Username: slack.Username, + IconURL: slack.IconURL, + }, nil +} + +// getSlackForkPayload composes Slack payload for forked by a repository. +func getSlackForkPayload(p *api.ForkPayload, slack *SlackMeta) (*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{ + Channel: slack.Channel, + Text: text, + Username: slack.Username, + IconURL: slack.IconURL, + }, nil +} + +func getSlackIssuesPayload(p *api.IssuePayload, 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.Issue.Title)) + var text, title, attachmentText string + switch p.Action { + case api.HookIssueOpened: + text = fmt.Sprintf("[%s] Issue submitted by %s", p.Repository.FullName, senderLink) + title = titleLink + attachmentText = SlackTextFormatter(p.Issue.Body) + case api.HookIssueClosed: + text = fmt.Sprintf("[%s] Issue closed: %s by %s", p.Repository.FullName, titleLink, senderLink) + case api.HookIssueReOpened: + text = fmt.Sprintf("[%s] Issue re-opened: %s by %s", p.Repository.FullName, titleLink, senderLink) + case api.HookIssueEdited: + text = fmt.Sprintf("[%s] Issue edited: %s by %s", p.Repository.FullName, titleLink, senderLink) + attachmentText = SlackTextFormatter(p.Issue.Body) + case api.HookIssueAssigned: + 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.HookIssueUnassigned: + text = fmt.Sprintf("[%s] Issue unassigned: %s by %s", p.Repository.FullName, titleLink, senderLink) + case api.HookIssueLabelUpdated: + text = fmt.Sprintf("[%s] Issue labels updated: %s by %s", p.Repository.FullName, titleLink, senderLink) + case api.HookIssueLabelCleared: + text = fmt.Sprintf("[%s] Issue labels cleared: %s by %s", p.Repository.FullName, titleLink, senderLink) + case api.HookIssueSynchronized: + text = fmt.Sprintf("[%s] Issue synchronized: %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.HookIssueCommentCreated: + text = fmt.Sprintf("[%s] New comment created by %s", p.Repository.FullName, senderLink) + title = titleLink + attachmentText = SlackTextFormatter(p.Comment.Body) + case api.HookIssueCommentEdited: + text = fmt.Sprintf("[%s] Comment edited by %s", p.Repository.FullName, senderLink) + title = titleLink + attachmentText = SlackTextFormatter(p.Comment.Body) + case api.HookIssueCommentDeleted: + 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 getSlackReleasePayload(p *api.ReleasePayload, slack *SlackMeta) (*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{ + Channel: slack.Channel, + Text: text, + Username: slack.Username, + IconURL: slack.IconURL, + }, nil +} + func getSlackPushPayload(p *api.PushPayload, slack *SlackMeta) (*SlackPayload, error) { // n new commits var ( @@ -238,12 +354,22 @@ func GetSlackPayload(p api.Payloader, event HookEventType, meta string) (*SlackP switch event { case HookEventCreate: return getSlackCreatePayload(p.(*api.CreatePayload), slack) + case HookEventDelete: + return getSlackDeletePayload(p.(*api.DeletePayload), slack) + case HookEventFork: + return getSlackForkPayload(p.(*api.ForkPayload), slack) + case HookEventIssues: + return getSlackIssuesPayload(p.(*api.IssuePayload), slack) + case HookEventIssueComment: + return getSlackIssueCommentPayload(p.(*api.IssueCommentPayload), slack) case HookEventPush: return getSlackPushPayload(p.(*api.PushPayload), slack) case HookEventPullRequest: return getSlackPullRequestPayload(p.(*api.PullRequestPayload), slack) case HookEventRepository: return getSlackRepositoryPayload(p.(*api.RepositoryPayload), slack) + case HookEventRelease: + return getSlackReleasePayload(p.(*api.ReleasePayload), slack) } return s, nil diff --git a/models/webhook_test.go b/models/webhook_test.go index eeae7efbcb..50106a3792 100644 --- a/models/webhook_test.go +++ b/models/webhook_test.go @@ -73,7 +73,7 @@ func TestWebhook_UpdateEvent(t *testing.T) { } func TestWebhook_EventsArray(t *testing.T) { - assert.Equal(t, []string{"create", "push", "pull_request"}, + assert.Equal(t, []string{"create", "delete", "fork", "push", "issues", "issue_comment", "pull_request", "repository", "release"}, (&Webhook{ HookEvent: &HookEvent{SendEverything: true}, }).EventsArray(), diff --git a/modules/auth/repo_form.go b/modules/auth/repo_form.go index 8b57e7eda9..a8c2270a2d 100644 --- a/modules/auth/repo_form.go +++ b/modules/auth/repo_form.go @@ -155,12 +155,17 @@ func (f *ProtectBranchForm) Validate(ctx *macaron.Context, errs binding.Errors) // WebhookForm form for changing web hook type WebhookForm struct { - Events string - Create bool - Push bool - PullRequest bool - Repository bool - Active bool + Events string + Create bool + Delete bool + Fork bool + Issues bool + IssueComment bool + Release bool + Push bool + PullRequest bool + Repository bool + Active bool } // PushOnly if the hook will be triggered when push diff --git a/modules/auth/user_form.go b/modules/auth/user_form.go index 5906abcd1d..1b00f62634 100644 --- a/modules/auth/user_form.go +++ b/modules/auth/user_form.go @@ -184,7 +184,7 @@ func (f *AddKeyForm) Validate(ctx *macaron.Context, errs binding.Errors) binding // NewAccessTokenForm form for creating access token type NewAccessTokenForm struct { - Name string `binding:"Required"` + Name string `binding:"Required;MaxSize(255)"` } // Validate valideates the fields diff --git a/options/locale/locale_de-DE.ini b/options/locale/locale_de-DE.ini index b5a6d537bd..156bf9175a 100644 --- a/options/locale/locale_de-DE.ini +++ b/options/locale/locale_de-DE.ini @@ -311,7 +311,6 @@ security=Sicherheit avatar=Profilbild ssh_gpg_keys=SSH / GPG Schlüssel social=Soziale Konten -applications=Zugriffstoken orgs=Organisationen verwalten repos=Repositories delete=Konto löschen diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 2544e37d26..91847f9e8f 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -1011,6 +1011,16 @@ settings.event_send_everything = All Events settings.event_choose = Custom Events… settings.event_create = Create settings.event_create_desc = Branch or tag created. +settings.event_delete = Delete +settings.event_delete_desc = Branch or tag deleted +settings.event_fork = Fork +settings.event_fork_desc = Repository forked +settings.event_issues = Issues +settings.event_issues_desc = Issue opened, closed, reopened, edited, assigned, unassigned, label updated, label cleared, milestoned, or demilestoned. +settings.event_issue_comment = Issue Comment +settings.event_issue_comment_desc = Issue comment created, edited, or deleted. +settings.event_release = Release +settings.event_release_desc = Release published in a repository. settings.event_pull_request = Pull Request settings.event_pull_request_desc = Pull request opened, closed, reopened, edited, assigned, unassigned, label updated, label cleared or synchronized. settings.event_push = Push diff --git a/options/locale/locale_pt-BR.ini b/options/locale/locale_pt-BR.ini index 7aed99a37c..090063bae0 100644 --- a/options/locale/locale_pt-BR.ini +++ b/options/locale/locale_pt-BR.ini @@ -306,12 +306,13 @@ form.name_pattern_not_allowed=O padrão de '%s' não é permitido em um nome de [settings] profile=Perfil +account=Conta password=Senha security=Segurança avatar=Avatar ssh_gpg_keys=Chaves SSH / GPG social=Contas sociais -applications=Tokens de acesso +applications=Aplicações orgs=Gerenciar organizações repos=Repositórios delete=Excluir conta @@ -334,7 +335,7 @@ continue=Continuar cancel=Cancelar language=Idioma -lookup_avatar_by_mail=Procure o avatar do endereço de e-mail +lookup_avatar_by_mail=Procurar o avatar do endereço de e-mail federated_avatar_lookup=Busca de avatar federativo enable_custom_avatar=Habilitar avatar customizado choose_new_avatar=Escolha um novo avatar @@ -344,7 +345,7 @@ uploaded_avatar_not_a_image=O arquivo enviado não é uma imagem. update_avatar_success=Seu avatar foi atualizado. change_password=Atualizar senha -old_password=Senha Atual +old_password=Senha atual new_password=Nova senha retype_new_password=Digite a nova senha novamente password_incorrect=A senha atual está incorreta. diff --git a/options/locale/locale_uk-UA.ini b/options/locale/locale_uk-UA.ini index c6487d7c4e..8ae7de9fa1 100644 --- a/options/locale/locale_uk-UA.ini +++ b/options/locale/locale_uk-UA.ini @@ -155,14 +155,14 @@ org_no_results=Відповідних організацій не знайден code_search_results=Результати пошуку '%s' [auth] -create_new_account=Реєстрація аккаунта +create_new_account=Реєстрація облікового запису register_helper_msg=Вже зареєстровані? Увійдіть зараз! disable_register_prompt=Вибачте, можливість реєстрації відключена. Будь ласка, зв'яжіться з адміністратором сайту. disable_register_mail=Підтвердження реєстрації електронною поштою вимкнено. remember_me=Запам'ятати мене forgot_password_title=Забув пароль forgot_password=Забули пароль? -sign_up_now=Потрібен аккаунт? Зареєструватися. +sign_up_now=Потрібен обліковий запис? Зареєструйтеся зараз. confirmation_mail_sent_prompt=Новий лист для підтвердження було відправлено на %s, будь ласка, перевірте вашу поштову скриньку протягом %s для завершення реєстрації. reset_password_mail_sent_prompt=Лист для підтвердження було відправлено на %s. Будь ласка, перевірте вашу поштову скриньку протягом %s для скидання пароля. active_your_account=Активувати обліковий запис @@ -264,16 +264,18 @@ form.name_reserved=Ім'я користувача "%s" зарезервован [settings] profile=Профіль +account=Обліковий запис password=Пароль security=Безпека avatar=Аватар ssh_gpg_keys=SSH / GPG ключі -social=Соціальні акаунти -applications=Токени Доступу +social=Соціальні облікові записи +applications=Додатки orgs=Керування організаціями repos=Репозиторії delete=Видалити обліковий запис twofa=Двофакторна авторизація +account_link=Прив'язані облікові записи organization=Організації uid=Ідентифікатор Uid @@ -339,7 +341,7 @@ show_openid=Показати у профілю hide_openid=Не показувати у профілі ssh_disabled=SSH вимкнено -manage_social=Керувати зв'язаними аккаунтами соціальних мереж +manage_social=Керувати зв'язаними обліковими записами соціальних мереж unbind=Від'єднати generate_new_token=Згенерувати новий токен @@ -350,6 +352,8 @@ delete_token=Видалити twofa_disable=Вимкнути двофакторну автентифікацію or_enter_secret=Або введіть секрет: %s +manage_account_links=Керування обліковими записами +remove_account_link=Видалити облікові записи delete_account=Видалити ваш обліковий запис @@ -479,6 +483,7 @@ issues.new.open_milestone=Активні етапи issues.new.closed_milestone=Закриті етапи issues.new.assignees=Виконавеці issues.new.clear_assignees=Прибрати виконавеців +issues.new.no_assignees=Ніхто не призначений issues.no_ref=Не вказана гілка або тег issues.create=Створити проблему issues.new_label=Нова мітка @@ -592,6 +597,7 @@ pulls.tab_commits=Коміти pulls.tab_files=Змінені файли pulls.reopen_to_merge=Будь ласка перевідкрийте цей запит щоб здіснити операцію злиття. pulls.merged=Злито +pulls.has_merged=Запит на злиття було об'єднано. pulls.can_auto_merge_desc=Цей запит можна об'єднати автоматично. pulls.merge_pull_request=Об'єднати запит на злиття @@ -671,6 +677,7 @@ search.search_repo=Пошук репозиторію settings=Налаштування settings.options=Репозиторій +settings.collaboration=Співробітники settings.collaboration.admin=Адміністратор settings.collaboration.write=Запис settings.collaboration.read=Читати @@ -696,6 +703,7 @@ settings.admin_settings=Налаштування адміністратора settings.danger_zone=Небезпечна зона settings.new_owner_has_same_repo=Новий власник вже має репозиторій з такою назвою. Будь ласка, виберіть інше ім'я. settings.convert=Перетворити на звичайний репозиторій +settings.convert_desc=Ви можете сконвертувати це дзеркало у звичайний репозиторій. Це не може бути скасовано. settings.transfer=Передати новому власнику settings.wiki_delete=Видалити Wiki-дані settings.confirm_wiki_delete=Видалити Wiki-дані @@ -724,6 +732,7 @@ settings.slack_icon_url=URL іконки settings.discord_username=Ім'я кристувача settings.discord_icon_url=URL іконки settings.slack_color=Колір +settings.event_push_only=Push події settings.event_send_everything=Всі події settings.event_create=Створити settings.event_create_desc=Гілку або тег створено. @@ -811,6 +820,7 @@ org_desc=Опис team_name=Назва команди team_desc=Опис team_permission_desc=Права доступу +team_unit_desc=Дозволити доступ до розділів репозиторію settings=Налаштування @@ -862,11 +872,13 @@ last_page=Остання total=Разом: %d dashboard.statistic=Підсумок +dashboard.system_status=Статус системи dashboard.operation_name=Назва операції dashboard.operation_switch=Перемкнути dashboard.operation_run=Запустити dashboard.delete_inactivate_accounts=Видалити всі неактивні облікові записи dashboard.delete_inactivate_accounts_success=Усі неактивні облікові записи успішно видалено. +dashboard.git_gc_repos_success=Всі репозиторії завершили збирання сміття. dashboard.server_uptime=Uptime серверу dashboard.current_memory_usage=Поточне використання пам'яті dashboard.total_memory_allocated=Виділено пам'яті загалом @@ -877,9 +889,14 @@ dashboard.mspan_structures_obtained=Отримано структур MSpan dashboard.mcache_structures_usage=Використання структур MCache dashboard.mcache_structures_obtained=Отримано структур MCache dashboard.profiling_bucket_hash_table_obtained=Отримано хеш-таблиць профілювання -dashboard.gc_metadata_obtained=Отримано метаданих GC +dashboard.gc_metadata_obtained=Отримано метаданих збирача сміття (GC) dashboard.other_system_allocation_obtained=Отримання інших виділень пам'яті -dashboard.next_gc_recycle=Наступний цикл GC +dashboard.next_gc_recycle=Наступний цикл збирача сміття (GC) +dashboard.last_gc_time=З останнього запуску збирача сміття (GC) +dashboard.total_gc_time=Загальна пауза збирача сміття (GC) +dashboard.total_gc_pause=Загальна пауза збирача сміття (GC) +dashboard.last_gc_pause=Остання пауза збирача сміття (GC) +dashboard.gc_times=Кількість запусків збирача сміття (GC) users.user_manage_panel=Керування обліковими записами користувачів users.new_account=Створити обліковий запис @@ -893,6 +910,7 @@ users.send_register_notify=Надіслати повідомлення про р users.edit=Редагувати users.auth_source=Джерело автентифікації users.local=Локальні +users.edit_account=Редагувати обліковий запис users.max_repo_creation=Максимальна кількість репозиторіїв users.max_repo_creation_desc=(Введіть -1, щоб використовувати глобальний ліміт за замовчуванням.) users.is_activated=Обліковий запис користувача увімкнено @@ -1036,7 +1054,7 @@ config.session_provider=Провайдер сесії config.provider_config=Конфігурація постачальника config.cookie_name=Ім'я файлу cookie config.enable_set_cookie=Увімкнути встановлення cookie -config.gc_interval_time=Інтервал запуску GC +config.gc_interval_time=Інтервал запуску збирача сміття (GC) config.session_life_time=Час життя сесії config.https_only=Тільки HTTPS config.cookie_life_time=Час життя cookie-файлу @@ -1050,12 +1068,12 @@ config.git_disable_diff_highlight=Вимкнути підсвітку синта config.git_max_diff_lines=Максимум рядків на diff (на один файл) config.git_max_diff_line_characters=Максимум символів на diff (на одну строку) config.git_max_diff_files=Максимум diff-файлів (для показу) -config.git_gc_args=Аргументи GC +config.git_gc_args=Аргументи збирача сміття (GC) config.git_migrate_timeout=Тайм-аут міграції config.git_mirror_timeout=Тайм-аут оновлення дзеркала config.git_clone_timeout=Тайм-аут операції клонування config.git_pull_timeout=Тайм-аут операції Pull -config.git_gc_timeout=Тайм-аут операції GC +config.git_gc_timeout=Тайм-аут операції збирача сміття (GC) config.log_config=Конфігурація журналу config.log_mode=Режим журналювання diff --git a/options/locale/locale_zh-CN.ini b/options/locale/locale_zh-CN.ini index e82892f08b..84db165ff6 100644 --- a/options/locale/locale_zh-CN.ini +++ b/options/locale/locale_zh-CN.ini @@ -306,12 +306,13 @@ form.name_pattern_not_allowed=用户名中不允许使用 "%s"。 [settings] profile=个人信息 +account=账号 password=修改密码 security=安全 avatar=头像设置 ssh_gpg_keys=SSH / GPG 密钥 social=社交帐号绑定 -applications=访问令牌(Access Tokens) +applications=应用 orgs=管理组织 repos=仓库列表 delete=删除帐户 @@ -329,7 +330,7 @@ location=所在地区 update_profile=更新信息 update_profile_success=您的资料信息已经更新 change_username=您的用户名已更改。 -change_username_prompt=注意:更改账户名将同时改变账户的URL +change_username_prompt=注意:更改账号名将同时改变账号的URL continue=继续操作 cancel=取消操作 language=界面语言 @@ -379,7 +380,7 @@ manage_ssh_keys=管理 SSH 密钥 manage_gpg_keys=管理 GPG 密钥 add_key=增加密钥 ssh_desc=这些 SSH 公钥已经关联到你的账号。相应的私钥拥有完全操作你的仓库的权限。 -gpg_desc=这些 GPG 公钥已经关联到你的账户。请妥善保管你的私钥因为他们将被用于认证提交。 +gpg_desc=这些 GPG 公钥已经关联到你的账号。请妥善保管你的私钥因为他们将被用于认证提交。 ssh_helper=需要帮助? 请查看有关 如何生成 SSH 密钥常见 SSH 问题 寻找答案。 gpg_helper=需要帮助吗?看一看 GitHub 关于GPG 的指导。 add_new_key=增加 SSH 密钥 @@ -422,31 +423,31 @@ unbind_success=社会帐户已从您的帐户中解除绑定。 manage_access_token=管理Access Tokens generate_new_token=生成新的令牌 tokens_desc=这些令牌拥有通过 Gitea API 对您的帐户的访问权限。 -new_token_desc=使用令牌的应用拥有完全访问你的账户的权限。 +new_token_desc=使用令牌的应用拥有完全访问你的账号的权限。 token_name=令牌名称 generate_token=生成令牌 generate_token_success=新令牌生成成功。请拷贝因为令牌将只会显示一次。 delete_token=删除令牌 access_token_deletion=删除Access Tokens access_token_deletion_desc=删除一个令牌将会组织通过它访问你账号的应用。是否继续? -delete_token_success=令牌已经被删除。使用该令牌的应用将不再能够访问你的账户。 +delete_token_success=令牌已经被删除。使用该令牌的应用将不再能够访问你的账号。 -twofa_desc=两步验证可以加强你的账户安全性。 -twofa_is_enrolled=你的账户已启用了两步验证。 +twofa_desc=两步验证可以加强你的账号安全性。 +twofa_is_enrolled=你的账号已启用了两步验证。 twofa_not_enrolled=你的账号未开启两步验证。 twofa_disable=禁用两步认证 twofa_scratch_token_regenerate=重新生成初始令牌 twofa_scratch_token_regenerated=你的初始令牌是 %s。请将它保存到一个安全的地方。 twofa_enroll=启用两步验证 twofa_disable_note=如果需要, 可以禁用双因素身份验证。 -twofa_disable_desc=关掉两步验证会使得您的账户不安全,继续执行? +twofa_disable_desc=关掉两步验证会使得您的账号不安全,继续执行? regenerate_scratch_token_desc=如果您丢失了您的验证口令或已经使用它登录, 您可以在这里重置它。 twofa_disabled=两步验证已被禁用。 scan_this_image=使用您的授权应用扫描这张图片: or_enter_secret=或者输入密钥:%s then_enter_passcode=并输入应用程序中显示的密码: passcode_invalid=密码不正确。再试一次。 -twofa_enrolled=你的账户已经启用了两步验证。请保存初始令牌(%s)到一个安全的地方,此令牌仅当前显示一次。 +twofa_enrolled=你的账号已经启用了两步验证。请保存初始令牌(%s)到一个安全的地方,此令牌仅当前显示一次。 manage_account_links=管理绑定过的账号 manage_account_links_desc=这些外部帐户已经绑定到您的 Gitea 帐户。 @@ -999,6 +1000,16 @@ settings.event_send_everything=所有事件 settings.event_choose=自定义事件... settings.event_create=创建 settings.event_create_desc=创建分支或标签 +settings.event_delete=刪除 +settings.event_delete_desc=删除分支或标签 +settings.event_fork=派生 +settings.event_fork_desc=仓库被派生 +settings.event_issues=工单 +settings.event_issues_desc=工单被开启、关闭、重新开启、编辑、指派、取消指派、更新标签、清除标签、设置里程碑或取消设置里程碑 +settings.event_issue_comment=工单评论 +settings.event_issue_comment_desc=工单评论被创建、编辑或删除 +settings.event_release=版本发布 +settings.event_release_desc=仓库发布新的版本。 settings.event_pull_request=合并请求 settings.event_pull_request_desc=开启、关闭、重新开启、编辑、指派、取消指派、更新标签、清除标签或同步合并请求 settings.event_push=推送 @@ -1129,7 +1140,7 @@ branch.restore_failed=未能还原分支%s。 branch.protected_deletion_failed=分支 '%s' 已被保护,不可删除。 topic.manage_topics=管理主题 -topic.done=已完成 +topic.done=保存 [org] org_name_holder=组织名称 diff --git a/routers/api/v1/repo/issue_comment.go b/routers/api/v1/repo/issue_comment.go index a9258849ea..2865ea9165 100644 --- a/routers/api/v1/repo/issue_comment.go +++ b/routers/api/v1/repo/issue_comment.go @@ -261,8 +261,9 @@ func editIssueComment(ctx *context.APIContext, form api.EditIssueCommentOption) return } + oldContent := comment.Content comment.Content = form.Body - if err := models.UpdateComment(comment); err != nil { + if err := models.UpdateComment(ctx.User, comment, oldContent); err != nil { ctx.Error(500, "UpdateComment", err) return } @@ -348,7 +349,7 @@ func deleteIssueComment(ctx *context.APIContext) { return } - if err = models.DeleteComment(comment); err != nil { + if err = models.DeleteComment(ctx.User, comment); err != nil { ctx.Error(500, "DeleteCommentByID", err) return } diff --git a/routers/api/v1/utils/hook.go b/routers/api/v1/utils/hook.go index e1533da94c..d0538ec54f 100644 --- a/routers/api/v1/utils/hook.go +++ b/routers/api/v1/utils/hook.go @@ -7,12 +7,13 @@ package utils import ( api "code.gitea.io/sdk/gitea" + "encoding/json" + "net/http" + "code.gitea.io/gitea/models" "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/routers/api/v1/convert" - "encoding/json" "github.com/Unknwon/com" - "net/http" ) // GetOrgHook get an organization's webhook. If there is an error, write to @@ -98,9 +99,15 @@ func addHook(ctx *context.APIContext, form *api.CreateHookOption, orgID, repoID HookEvent: &models.HookEvent{ ChooseEvents: true, HookEvents: models.HookEvents{ - Create: com.IsSliceContainsStr(form.Events, string(models.HookEventCreate)), - Push: com.IsSliceContainsStr(form.Events, string(models.HookEventPush)), - PullRequest: com.IsSliceContainsStr(form.Events, string(models.HookEventPullRequest)), + Create: com.IsSliceContainsStr(form.Events, string(models.HookEventCreate)), + Delete: com.IsSliceContainsStr(form.Events, string(models.HookEventDelete)), + Fork: com.IsSliceContainsStr(form.Events, string(models.HookEventFork)), + Issues: com.IsSliceContainsStr(form.Events, string(models.HookEventIssues)), + IssueComment: com.IsSliceContainsStr(form.Events, string(models.HookEventIssueComment)), + Push: com.IsSliceContainsStr(form.Events, string(models.HookEventPush)), + PullRequest: com.IsSliceContainsStr(form.Events, string(models.HookEventPullRequest)), + Repository: com.IsSliceContainsStr(form.Events, string(models.HookEventRepository)), + Release: com.IsSliceContainsStr(form.Events, string(models.HookEventRelease)), }, }, IsActive: form.Active, @@ -211,6 +218,16 @@ func editHook(ctx *context.APIContext, form *api.EditHookOption, w *models.Webho w.Create = com.IsSliceContainsStr(form.Events, string(models.HookEventCreate)) w.Push = com.IsSliceContainsStr(form.Events, string(models.HookEventPush)) w.PullRequest = com.IsSliceContainsStr(form.Events, string(models.HookEventPullRequest)) + w.Create = com.IsSliceContainsStr(form.Events, string(models.HookEventCreate)) + w.Delete = com.IsSliceContainsStr(form.Events, string(models.HookEventDelete)) + w.Fork = com.IsSliceContainsStr(form.Events, string(models.HookEventFork)) + w.Issues = com.IsSliceContainsStr(form.Events, string(models.HookEventIssues)) + w.IssueComment = com.IsSliceContainsStr(form.Events, string(models.HookEventIssueComment)) + w.Push = com.IsSliceContainsStr(form.Events, string(models.HookEventPush)) + w.PullRequest = com.IsSliceContainsStr(form.Events, string(models.HookEventPullRequest)) + w.Repository = com.IsSliceContainsStr(form.Events, string(models.HookEventRepository)) + w.Release = com.IsSliceContainsStr(form.Events, string(models.HookEventRelease)) + if err := w.UpdateEvent(); err != nil { ctx.Error(500, "UpdateEvent", err) return false diff --git a/routers/org/setting.go b/routers/org/setting.go index 937697d07e..7f652c11d6 100644 --- a/routers/org/setting.go +++ b/routers/org/setting.go @@ -13,7 +13,7 @@ import ( "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/routers/user" + userSetting "code.gitea.io/gitea/routers/user/setting" ) const ( @@ -91,7 +91,7 @@ func SettingsPost(ctx *context.Context, form auth.UpdateOrgSettingForm) { // SettingsAvatar response for change avatar on settings page func SettingsAvatar(ctx *context.Context, form auth.AvatarForm) { form.Source = auth.AvatarLocal - if err := user.UpdateAvatarSetting(ctx, form, ctx.Org.Organization); err != nil { + if err := userSetting.UpdateAvatarSetting(ctx, form, ctx.Org.Organization); err != nil { ctx.Flash.Error(err.Error()) } else { ctx.Flash.Success(ctx.Tr("org.settings.update_avatar_success")) diff --git a/routers/repo/issue.go b/routers/repo/issue.go index c18f0bb352..19bf346cf8 100644 --- a/routers/repo/issue.go +++ b/routers/repo/issue.go @@ -1102,6 +1102,7 @@ func UpdateCommentContent(ctx *context.Context) { return } + oldContent := comment.Content comment.Content = ctx.Query("content") if len(comment.Content) == 0 { ctx.JSON(200, map[string]interface{}{ @@ -1109,7 +1110,7 @@ func UpdateCommentContent(ctx *context.Context) { }) return } - if err = models.UpdateComment(comment); err != nil { + if err = models.UpdateComment(ctx.User, comment, oldContent); err != nil { ctx.ServerError("UpdateComment", err) return } @@ -1135,7 +1136,7 @@ func DeleteComment(ctx *context.Context) { return } - if err = models.DeleteComment(comment); err != nil { + if err = models.DeleteComment(ctx.User, comment); err != nil { ctx.ServerError("DeleteCommentByID", err) return } diff --git a/routers/repo/webhook.go b/routers/repo/webhook.go index 35fdf796b5..6994aa3344 100644 --- a/routers/repo/webhook.go +++ b/routers/repo/webhook.go @@ -23,9 +23,9 @@ import ( ) const ( - tplHooks base.TplName = "repo/settings/hooks" - tplHookNew base.TplName = "repo/settings/hook_new" - tplOrgHookNew base.TplName = "org/settings/hook_new" + tplHooks base.TplName = "repo/settings/webhook/base" + tplHookNew base.TplName = "repo/settings/webhook/new" + tplOrgHookNew base.TplName = "org/settings/webhook/new" ) // Webhooks render web hooks list page @@ -118,10 +118,15 @@ func ParseHookEvent(form auth.WebhookForm) *models.HookEvent { SendEverything: form.SendEverything(), ChooseEvents: form.ChooseEvents(), HookEvents: models.HookEvents{ - Create: form.Create, - Push: form.Push, - PullRequest: form.PullRequest, - Repository: form.Repository, + Create: form.Create, + Delete: form.Delete, + Fork: form.Fork, + Issues: form.Issues, + IssueComment: form.IssueComment, + Release: form.Release, + Push: form.Push, + PullRequest: form.PullRequest, + Repository: form.Repository, }, } } diff --git a/routers/routes/routes.go b/routers/routes/routes.go index f51d62d4d4..6cd84e9f34 100644 --- a/routers/routes/routes.go +++ b/routers/routes/routes.go @@ -27,6 +27,7 @@ import ( "code.gitea.io/gitea/routers/private" "code.gitea.io/gitea/routers/repo" "code.gitea.io/gitea/routers/user" + userSetting "code.gitea.io/gitea/routers/user/setting" "github.com/go-macaron/binding" "github.com/go-macaron/cache" @@ -216,39 +217,39 @@ func RegisterRoutes(m *macaron.Macaron) { }, reqSignOut) m.Group("/user/settings", func() { - m.Get("", user.Settings) - m.Post("", bindIgnErr(auth.UpdateProfileForm{}), user.SettingsPost) - m.Post("/avatar", binding.MultipartForm(auth.AvatarForm{}), user.SettingsAvatarPost) - m.Post("/avatar/delete", user.SettingsDeleteAvatar) + m.Get("", userSetting.Profile) + m.Post("", bindIgnErr(auth.UpdateProfileForm{}), userSetting.ProfilePost) + m.Post("/avatar", binding.MultipartForm(auth.AvatarForm{}), userSetting.AvatarPost) + m.Post("/avatar/delete", userSetting.DeleteAvatar) m.Group("/account", func() { - m.Combo("").Get(user.SettingsAccount).Post(bindIgnErr(auth.ChangePasswordForm{}), user.SettingsAccountPost) - m.Post("/email", bindIgnErr(auth.AddEmailForm{}), user.SettingsEmailPost) - m.Post("/email/delete", user.DeleteEmail) - m.Post("/delete", user.SettingsDelete) + m.Combo("").Get(userSetting.Account).Post(bindIgnErr(auth.ChangePasswordForm{}), userSetting.AccountPost) + m.Post("/email", bindIgnErr(auth.AddEmailForm{}), userSetting.EmailPost) + m.Post("/email/delete", userSetting.DeleteEmail) + m.Post("/delete", userSetting.DeleteAccount) }) m.Group("/security", func() { - m.Get("", user.SettingsSecurity) + m.Get("", userSetting.Security) m.Group("/two_factor", func() { - m.Post("/regenerate_scratch", user.SettingsTwoFactorRegenerateScratch) - m.Post("/disable", user.SettingsTwoFactorDisable) - m.Get("/enroll", user.SettingsTwoFactorEnroll) - m.Post("/enroll", bindIgnErr(auth.TwoFactorAuthForm{}), user.SettingsTwoFactorEnrollPost) + m.Post("/regenerate_scratch", userSetting.RegenerateScratchTwoFactor) + m.Post("/disable", userSetting.DisableTwoFactor) + m.Get("/enroll", userSetting.EnrollTwoFactor) + m.Post("/enroll", bindIgnErr(auth.TwoFactorAuthForm{}), userSetting.EnrollTwoFactorPost) }) m.Group("/openid", func() { - m.Post("", bindIgnErr(auth.AddOpenIDForm{}), user.SettingsOpenIDPost) - m.Post("/delete", user.DeleteOpenID) - m.Post("/toggle_visibility", user.ToggleOpenIDVisibility) + m.Post("", bindIgnErr(auth.AddOpenIDForm{}), userSetting.OpenIDPost) + m.Post("/delete", userSetting.DeleteOpenID) + m.Post("/toggle_visibility", userSetting.ToggleOpenIDVisibility) }, openIDSignInEnabled) - m.Post("/account_link", user.SettingsDeleteAccountLink) + m.Post("/account_link", userSetting.DeleteAccountLink) }) - m.Combo("/applications").Get(user.SettingsApplications). - Post(bindIgnErr(auth.NewAccessTokenForm{}), user.SettingsApplicationsPost) - m.Post("/applications/delete", user.SettingsDeleteApplication) - m.Combo("/keys").Get(user.SettingsKeys). - Post(bindIgnErr(auth.AddKeyForm{}), user.SettingsKeysPost) - m.Post("/keys/delete", user.DeleteKey) - m.Get("/organization", user.SettingsOrganization) - m.Get("/repos", user.SettingsRepos) + m.Combo("/applications").Get(userSetting.Applications). + Post(bindIgnErr(auth.NewAccessTokenForm{}), userSetting.ApplicationsPost) + m.Post("/applications/delete", userSetting.DeleteApplication) + m.Combo("/keys").Get(userSetting.Keys). + Post(bindIgnErr(auth.AddKeyForm{}), userSetting.KeysPost) + m.Post("/keys/delete", userSetting.DeleteKey) + m.Get("/organization", userSetting.Organization) + m.Get("/repos", userSetting.Repos) // redirects from old settings urls to new ones // TODO: can be removed on next major version diff --git a/routers/user/setting.go b/routers/user/setting.go deleted file mode 100644 index 1c760e210c..0000000000 --- a/routers/user/setting.go +++ /dev/null @@ -1,808 +0,0 @@ -// 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 user - -import ( - "bytes" - "errors" - "fmt" - "io/ioutil" - "strings" - - "github.com/Unknwon/com" - "github.com/Unknwon/i18n" - "github.com/pquerna/otp" - "github.com/pquerna/otp/totp" - - "encoding/base64" - "html/template" - "image/png" - - "code.gitea.io/gitea/models" - "code.gitea.io/gitea/modules/auth" - "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/context" - "code.gitea.io/gitea/modules/log" - "code.gitea.io/gitea/modules/setting" -) - -const ( - tplSettingsProfile base.TplName = "user/settings/profile" - tplSettingsAccount base.TplName = "user/settings/account" - tplSettingsSecurity base.TplName = "user/settings/security" - tplSettingsTwofaEnroll base.TplName = "user/settings/twofa_enroll" - tplSettingsApplications base.TplName = "user/settings/applications" - tplSettingsKeys base.TplName = "user/settings/keys" - tplSettingsOrganization base.TplName = "user/settings/organization" - tplSettingsRepositories base.TplName = "user/settings/repos" -) - -// Settings render user's profile page -func Settings(ctx *context.Context) { - ctx.Data["Title"] = ctx.Tr("settings") - ctx.Data["PageIsSettingsProfile"] = true - ctx.HTML(200, tplSettingsProfile) -} - -func handleUsernameChange(ctx *context.Context, newName string) { - // Non-local users are not allowed to change their username. - if len(newName) == 0 || !ctx.User.IsLocal() { - return - } - - // Check if user name has been changed - if ctx.User.LowerName != strings.ToLower(newName) { - if err := models.ChangeUserName(ctx.User, newName); err != nil { - switch { - case models.IsErrUserAlreadyExist(err): - ctx.Flash.Error(ctx.Tr("form.username_been_taken")) - ctx.Redirect(setting.AppSubURL + "/user/settings") - case models.IsErrEmailAlreadyUsed(err): - ctx.Flash.Error(ctx.Tr("form.email_been_used")) - ctx.Redirect(setting.AppSubURL + "/user/settings") - case models.IsErrNameReserved(err): - ctx.Flash.Error(ctx.Tr("user.form.name_reserved", newName)) - ctx.Redirect(setting.AppSubURL + "/user/settings") - case models.IsErrNamePatternNotAllowed(err): - ctx.Flash.Error(ctx.Tr("user.form.name_pattern_not_allowed", newName)) - ctx.Redirect(setting.AppSubURL + "/user/settings") - default: - ctx.ServerError("ChangeUserName", err) - } - return - } - log.Trace("User name changed: %s -> %s", ctx.User.Name, newName) - } - - // In case it's just a case change - ctx.User.Name = newName - ctx.User.LowerName = strings.ToLower(newName) -} - -// SettingsPost response for change user's profile -func SettingsPost(ctx *context.Context, form auth.UpdateProfileForm) { - ctx.Data["Title"] = ctx.Tr("settings") - ctx.Data["PageIsSettingsProfile"] = true - - if ctx.HasError() { - ctx.HTML(200, tplSettingsProfile) - return - } - - handleUsernameChange(ctx, form.Name) - if ctx.Written() { - return - } - - ctx.User.FullName = form.FullName - ctx.User.Email = form.Email - ctx.User.KeepEmailPrivate = form.KeepEmailPrivate - ctx.User.Website = form.Website - ctx.User.Location = form.Location - ctx.User.Language = form.Language - if err := models.UpdateUserSetting(ctx.User); err != nil { - if _, ok := err.(models.ErrEmailAlreadyUsed); ok { - ctx.Flash.Error(ctx.Tr("form.email_been_used")) - ctx.Redirect(setting.AppSubURL + "/user/settings") - return - } - ctx.ServerError("UpdateUser", err) - return - } - - // Update the language to the one we just set - ctx.SetCookie("lang", ctx.User.Language, nil, setting.AppSubURL) - - log.Trace("User settings updated: %s", ctx.User.Name) - ctx.Flash.Success(i18n.Tr(ctx.User.Language, "settings.update_profile_success")) - ctx.Redirect(setting.AppSubURL + "/user/settings") -} - -// UpdateAvatarSetting update user's avatar -// FIXME: limit size. -func UpdateAvatarSetting(ctx *context.Context, form auth.AvatarForm, ctxUser *models.User) error { - ctxUser.UseCustomAvatar = form.Source == auth.AvatarLocal - if len(form.Gravatar) > 0 { - ctxUser.Avatar = base.EncodeMD5(form.Gravatar) - ctxUser.AvatarEmail = form.Gravatar - } - - if form.Avatar != nil { - fr, err := form.Avatar.Open() - if err != nil { - return fmt.Errorf("Avatar.Open: %v", err) - } - defer fr.Close() - - data, err := ioutil.ReadAll(fr) - if err != nil { - return fmt.Errorf("ioutil.ReadAll: %v", err) - } - if !base.IsImageFile(data) { - return errors.New(ctx.Tr("settings.uploaded_avatar_not_a_image")) - } - if err = ctxUser.UploadAvatar(data); err != nil { - return fmt.Errorf("UploadAvatar: %v", err) - } - } else { - // No avatar is uploaded but setting has been changed to enable, - // generate a random one when needed. - if ctxUser.UseCustomAvatar && !com.IsFile(ctxUser.CustomAvatarPath()) { - if err := ctxUser.GenerateRandomAvatar(); err != nil { - log.Error(4, "GenerateRandomAvatar[%d]: %v", ctxUser.ID, err) - } - } - } - - if err := models.UpdateUserCols(ctxUser, "avatar", "avatar_email", "use_custom_avatar"); err != nil { - return fmt.Errorf("UpdateUser: %v", err) - } - - return nil -} - -// SettingsAvatarPost response for change user's avatar request -func SettingsAvatarPost(ctx *context.Context, form auth.AvatarForm) { - if err := UpdateAvatarSetting(ctx, form, ctx.User); err != nil { - ctx.Flash.Error(err.Error()) - } else { - ctx.Flash.Success(ctx.Tr("settings.update_avatar_success")) - } - - ctx.Redirect(setting.AppSubURL + "/user/settings") -} - -// SettingsDeleteAvatar render delete avatar page -func SettingsDeleteAvatar(ctx *context.Context) { - if err := ctx.User.DeleteAvatar(); err != nil { - ctx.Flash.Error(err.Error()) - } - - ctx.Redirect(setting.AppSubURL + "/user/settings") -} - -// SettingsAccount renders change user's password, user's email and user suicide page -func SettingsAccount(ctx *context.Context) { - ctx.Data["Title"] = ctx.Tr("settings") - ctx.Data["PageIsSettingsAccount"] = true - ctx.Data["Email"] = ctx.User.Email - - emails, err := models.GetEmailAddresses(ctx.User.ID) - if err != nil { - ctx.ServerError("GetEmailAddresses", err) - return - } - ctx.Data["Emails"] = emails - - ctx.HTML(200, tplSettingsAccount) -} - -// SettingsAccountPost response for change user's password -func SettingsAccountPost(ctx *context.Context, form auth.ChangePasswordForm) { - ctx.Data["Title"] = ctx.Tr("settings") - ctx.Data["PageIsSettingsAccount"] = true - - if ctx.HasError() { - ctx.HTML(200, tplSettingsAccount) - return - } - - if len(form.Password) < setting.MinPasswordLength { - ctx.Flash.Error(ctx.Tr("auth.password_too_short", setting.MinPasswordLength)) - } else if ctx.User.IsPasswordSet() && !ctx.User.ValidatePassword(form.OldPassword) { - ctx.Flash.Error(ctx.Tr("settings.password_incorrect")) - } else if form.Password != form.Retype { - ctx.Flash.Error(ctx.Tr("form.password_not_match")) - } else { - var err error - if ctx.User.Salt, err = models.GetUserSalt(); err != nil { - ctx.ServerError("UpdateUser", err) - return - } - ctx.User.HashPassword(form.Password) - if err := models.UpdateUserCols(ctx.User, "salt", "passwd"); err != nil { - ctx.ServerError("UpdateUser", err) - return - } - log.Trace("User password updated: %s", ctx.User.Name) - ctx.Flash.Success(ctx.Tr("settings.change_password_success")) - } - - ctx.Redirect(setting.AppSubURL + "/user/settings/account") -} - -// SettingsEmailPost response for change user's email -func SettingsEmailPost(ctx *context.Context, form auth.AddEmailForm) { - ctx.Data["Title"] = ctx.Tr("settings") - ctx.Data["PageIsSettingsAccount"] = true - - // Make emailaddress primary. - if ctx.Query("_method") == "PRIMARY" { - if err := models.MakeEmailPrimary(&models.EmailAddress{ID: ctx.QueryInt64("id")}); err != nil { - ctx.ServerError("MakeEmailPrimary", err) - return - } - - log.Trace("Email made primary: %s", ctx.User.Name) - ctx.Redirect(setting.AppSubURL + "/user/settings/account") - return - } - - // Add Email address. - emails, err := models.GetEmailAddresses(ctx.User.ID) - if err != nil { - ctx.ServerError("GetEmailAddresses", err) - return - } - ctx.Data["Emails"] = emails - - if ctx.HasError() { - ctx.HTML(200, tplSettingsAccount) - return - } - - email := &models.EmailAddress{ - UID: ctx.User.ID, - Email: form.Email, - IsActivated: !setting.Service.RegisterEmailConfirm, - } - if err := models.AddEmailAddress(email); err != nil { - if models.IsErrEmailAlreadyUsed(err) { - ctx.RenderWithErr(ctx.Tr("form.email_been_used"), tplSettingsAccount, &form) - return - } - ctx.ServerError("AddEmailAddress", err) - return - } - - // Send confirmation email - if setting.Service.RegisterEmailConfirm { - models.SendActivateEmailMail(ctx.Context, ctx.User, email) - - if err := ctx.Cache.Put("MailResendLimit_"+ctx.User.LowerName, ctx.User.LowerName, 180); err != nil { - log.Error(4, "Set cache(MailResendLimit) fail: %v", err) - } - ctx.Flash.Info(ctx.Tr("settings.add_email_confirmation_sent", email.Email, base.MinutesToFriendly(setting.Service.ActiveCodeLives, ctx.Locale.Language()))) - } else { - ctx.Flash.Success(ctx.Tr("settings.add_email_success")) - } - - log.Trace("Email address added: %s", email.Email) - ctx.Redirect(setting.AppSubURL + "/user/settings/account") -} - -// DeleteEmail response for delete user's email -func DeleteEmail(ctx *context.Context) { - if err := models.DeleteEmailAddress(&models.EmailAddress{ID: ctx.QueryInt64("id"), UID: ctx.User.ID}); err != nil { - ctx.ServerError("DeleteEmail", err) - return - } - log.Trace("Email address deleted: %s", ctx.User.Name) - - ctx.Flash.Success(ctx.Tr("settings.email_deletion_success")) - ctx.JSON(200, map[string]interface{}{ - "redirect": setting.AppSubURL + "/user/settings/account", - }) -} - -// SettingsDelete render user suicide page and response for delete user himself -func SettingsDelete(ctx *context.Context) { - ctx.Data["Title"] = ctx.Tr("settings") - ctx.Data["PageIsSettingsAccount"] = true - - if _, err := models.UserSignIn(ctx.User.Name, ctx.Query("password")); err != nil { - if models.IsErrUserNotExist(err) { - ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_password"), tplSettingsAccount, nil) - } else { - ctx.ServerError("UserSignIn", err) - } - return - } - - if err := models.DeleteUser(ctx.User); err != nil { - switch { - case models.IsErrUserOwnRepos(err): - ctx.Flash.Error(ctx.Tr("form.still_own_repo")) - ctx.Redirect(setting.AppSubURL + "/user/settings/account") - case models.IsErrUserHasOrgs(err): - ctx.Flash.Error(ctx.Tr("form.still_has_org")) - ctx.Redirect(setting.AppSubURL + "/user/settings/account") - default: - ctx.ServerError("DeleteUser", err) - } - } else { - log.Trace("Account deleted: %s", ctx.User.Name) - ctx.Redirect(setting.AppSubURL + "/") - } -} - -// SettingsSecurity render change user's password page and 2FA -func SettingsSecurity(ctx *context.Context) { - ctx.Data["Title"] = ctx.Tr("settings") - ctx.Data["PageIsSettingsSecurity"] = true - - enrolled := true - _, err := models.GetTwoFactorByUID(ctx.User.ID) - if err != nil { - if models.IsErrTwoFactorNotEnrolled(err) { - enrolled = false - } else { - ctx.ServerError("SettingsTwoFactor", err) - return - } - } - ctx.Data["TwofaEnrolled"] = enrolled - - accountLinks, err := models.ListAccountLinks(ctx.User) - if err != nil { - ctx.ServerError("ListAccountLinks", err) - return - } - - // map the provider display name with the LoginSource - sources := make(map[*models.LoginSource]string) - for _, externalAccount := range accountLinks { - if loginSource, err := models.GetLoginSourceByID(externalAccount.LoginSourceID); err == nil { - var providerDisplayName string - if loginSource.IsOAuth2() { - providerTechnicalName := loginSource.OAuth2().Provider - providerDisplayName = models.OAuth2Providers[providerTechnicalName].DisplayName - } else { - providerDisplayName = loginSource.Name - } - sources[loginSource] = providerDisplayName - } - } - ctx.Data["AccountLinks"] = sources - - if ctx.Query("openid.return_to") != "" { - settingsOpenIDVerify(ctx) - return - } - - openid, err := models.GetUserOpenIDs(ctx.User.ID) - if err != nil { - ctx.ServerError("GetUserOpenIDs", err) - return - } - ctx.Data["OpenIDs"] = openid - - ctx.HTML(200, tplSettingsSecurity) -} - -// SettingsDeleteAccountLink delete a single account link -func SettingsDeleteAccountLink(ctx *context.Context) { - if _, err := models.RemoveAccountLink(ctx.User, ctx.QueryInt64("loginSourceID")); err != nil { - ctx.Flash.Error("RemoveAccountLink: " + err.Error()) - } else { - ctx.Flash.Success(ctx.Tr("settings.remove_account_link_success")) - } - - ctx.JSON(200, map[string]interface{}{ - "redirect": setting.AppSubURL + "/user/settings/security", - }) -} - -// SettingsApplications render manage access token page -func SettingsApplications(ctx *context.Context) { - ctx.Data["Title"] = ctx.Tr("settings") - ctx.Data["PageIsSettingsApplications"] = true - - tokens, err := models.ListAccessTokens(ctx.User.ID) - if err != nil { - ctx.ServerError("ListAccessTokens", err) - return - } - ctx.Data["Tokens"] = tokens - - ctx.HTML(200, tplSettingsApplications) -} - -// SettingsApplicationsPost response for add user's access token -func SettingsApplicationsPost(ctx *context.Context, form auth.NewAccessTokenForm) { - ctx.Data["Title"] = ctx.Tr("settings") - ctx.Data["PageIsSettingsApplications"] = true - - if ctx.HasError() { - tokens, err := models.ListAccessTokens(ctx.User.ID) - if err != nil { - ctx.ServerError("ListAccessTokens", err) - return - } - ctx.Data["Tokens"] = tokens - ctx.HTML(200, tplSettingsApplications) - return - } - - t := &models.AccessToken{ - UID: ctx.User.ID, - Name: form.Name, - } - if err := models.NewAccessToken(t); err != nil { - ctx.ServerError("NewAccessToken", err) - return - } - - ctx.Flash.Success(ctx.Tr("settings.generate_token_success")) - ctx.Flash.Info(t.Sha1) - - ctx.Redirect(setting.AppSubURL + "/user/settings/applications") -} - -// SettingsDeleteApplication response for delete user access token -func SettingsDeleteApplication(ctx *context.Context) { - if err := models.DeleteAccessTokenByID(ctx.QueryInt64("id"), ctx.User.ID); err != nil { - ctx.Flash.Error("DeleteAccessTokenByID: " + err.Error()) - } else { - ctx.Flash.Success(ctx.Tr("settings.delete_token_success")) - } - - ctx.JSON(200, map[string]interface{}{ - "redirect": setting.AppSubURL + "/user/settings/applications", - }) -} - -// SettingsKeys render user's SSH/GPG public keys page -func SettingsKeys(ctx *context.Context) { - ctx.Data["Title"] = ctx.Tr("settings") - ctx.Data["PageIsSettingsKeys"] = true - ctx.Data["DisableSSH"] = setting.SSH.Disabled - - keys, err := models.ListPublicKeys(ctx.User.ID) - if err != nil { - ctx.ServerError("ListPublicKeys", err) - return - } - ctx.Data["Keys"] = keys - - gpgkeys, err := models.ListGPGKeys(ctx.User.ID) - if err != nil { - ctx.ServerError("ListGPGKeys", err) - return - } - ctx.Data["GPGKeys"] = gpgkeys - - ctx.HTML(200, tplSettingsKeys) -} - -// SettingsKeysPost response for change user's SSH/GPG keys -func SettingsKeysPost(ctx *context.Context, form auth.AddKeyForm) { - ctx.Data["Title"] = ctx.Tr("settings") - ctx.Data["PageIsSettingsKeys"] = true - - keys, err := models.ListPublicKeys(ctx.User.ID) - if err != nil { - ctx.ServerError("ListPublicKeys", err) - return - } - ctx.Data["Keys"] = keys - - gpgkeys, err := models.ListGPGKeys(ctx.User.ID) - if err != nil { - ctx.ServerError("ListGPGKeys", err) - return - } - ctx.Data["GPGKeys"] = gpgkeys - - if ctx.HasError() { - ctx.HTML(200, tplSettingsKeys) - return - } - switch form.Type { - case "gpg": - key, err := models.AddGPGKey(ctx.User.ID, form.Content) - if err != nil { - ctx.Data["HasGPGError"] = true - switch { - case models.IsErrGPGKeyParsing(err): - ctx.Flash.Error(ctx.Tr("form.invalid_gpg_key", err.Error())) - ctx.Redirect(setting.AppSubURL + "/user/settings/keys") - case models.IsErrGPGKeyIDAlreadyUsed(err): - ctx.Data["Err_Content"] = true - ctx.RenderWithErr(ctx.Tr("settings.gpg_key_id_used"), tplSettingsKeys, &form) - case models.IsErrGPGNoEmailFound(err): - ctx.Data["Err_Content"] = true - ctx.RenderWithErr(ctx.Tr("settings.gpg_no_key_email_found"), tplSettingsKeys, &form) - default: - ctx.ServerError("AddPublicKey", err) - } - return - } - ctx.Flash.Success(ctx.Tr("settings.add_gpg_key_success", key.KeyID)) - ctx.Redirect(setting.AppSubURL + "/user/settings/keys") - case "ssh": - content, err := models.CheckPublicKeyString(form.Content) - if err != nil { - if models.IsErrSSHDisabled(err) { - ctx.Flash.Info(ctx.Tr("settings.ssh_disabled")) - } else if models.IsErrKeyUnableVerify(err) { - ctx.Flash.Info(ctx.Tr("form.unable_verify_ssh_key")) - } else { - ctx.Flash.Error(ctx.Tr("form.invalid_ssh_key", err.Error())) - } - ctx.Redirect(setting.AppSubURL + "/user/settings/keys") - return - } - - if _, err = models.AddPublicKey(ctx.User.ID, form.Title, content); err != nil { - ctx.Data["HasSSHError"] = true - switch { - case models.IsErrKeyAlreadyExist(err): - ctx.Data["Err_Content"] = true - ctx.RenderWithErr(ctx.Tr("settings.ssh_key_been_used"), tplSettingsKeys, &form) - case models.IsErrKeyNameAlreadyUsed(err): - ctx.Data["Err_Title"] = true - ctx.RenderWithErr(ctx.Tr("settings.ssh_key_name_used"), tplSettingsKeys, &form) - default: - ctx.ServerError("AddPublicKey", err) - } - return - } - ctx.Flash.Success(ctx.Tr("settings.add_key_success", form.Title)) - ctx.Redirect(setting.AppSubURL + "/user/settings/keys") - - default: - ctx.Flash.Warning("Function not implemented") - ctx.Redirect(setting.AppSubURL + "/user/settings/keys") - } - -} - -// DeleteKey response for delete user's SSH/GPG key -func DeleteKey(ctx *context.Context) { - - switch ctx.Query("type") { - case "gpg": - if err := models.DeleteGPGKey(ctx.User, ctx.QueryInt64("id")); err != nil { - ctx.Flash.Error("DeleteGPGKey: " + err.Error()) - } else { - ctx.Flash.Success(ctx.Tr("settings.gpg_key_deletion_success")) - } - case "ssh": - if err := models.DeletePublicKey(ctx.User, ctx.QueryInt64("id")); err != nil { - ctx.Flash.Error("DeletePublicKey: " + err.Error()) - } else { - ctx.Flash.Success(ctx.Tr("settings.ssh_key_deletion_success")) - } - default: - ctx.Flash.Warning("Function not implemented") - ctx.Redirect(setting.AppSubURL + "/user/settings/keys") - } - ctx.JSON(200, map[string]interface{}{ - "redirect": setting.AppSubURL + "/user/settings/keys", - }) -} - -// SettingsTwoFactorRegenerateScratch regenerates the user's 2FA scratch code. -func SettingsTwoFactorRegenerateScratch(ctx *context.Context) { - ctx.Data["Title"] = ctx.Tr("settings") - ctx.Data["PageIsSettingsSecurity"] = true - - t, err := models.GetTwoFactorByUID(ctx.User.ID) - if err != nil { - ctx.ServerError("SettingsTwoFactor", err) - return - } - - if err = t.GenerateScratchToken(); err != nil { - ctx.ServerError("SettingsTwoFactor", err) - return - } - - if err = models.UpdateTwoFactor(t); err != nil { - ctx.ServerError("SettingsTwoFactor", err) - return - } - - ctx.Flash.Success(ctx.Tr("settings.twofa_scratch_token_regenerated", t.ScratchToken)) - ctx.Redirect(setting.AppSubURL + "/user/settings/security") -} - -// SettingsTwoFactorDisable deletes the user's 2FA settings. -func SettingsTwoFactorDisable(ctx *context.Context) { - ctx.Data["Title"] = ctx.Tr("settings") - ctx.Data["PageIsSettingsSecurity"] = true - - t, err := models.GetTwoFactorByUID(ctx.User.ID) - if err != nil { - ctx.ServerError("SettingsTwoFactor", err) - return - } - - if err = models.DeleteTwoFactorByID(t.ID, ctx.User.ID); err != nil { - ctx.ServerError("SettingsTwoFactor", err) - return - } - - ctx.Flash.Success(ctx.Tr("settings.twofa_disabled")) - ctx.Redirect(setting.AppSubURL + "/user/settings/security") -} - -func twofaGenerateSecretAndQr(ctx *context.Context) bool { - var otpKey *otp.Key - var err error - uri := ctx.Session.Get("twofaUri") - if uri != nil { - otpKey, err = otp.NewKeyFromURL(uri.(string)) - } - if otpKey == nil { - err = nil // clear the error, in case the URL was invalid - otpKey, err = totp.Generate(totp.GenerateOpts{ - Issuer: setting.AppName + " (" + strings.TrimRight(setting.AppURL, "/") + ")", - AccountName: ctx.User.Name, - }) - if err != nil { - ctx.ServerError("SettingsTwoFactor", err) - return false - } - } - - ctx.Data["TwofaSecret"] = otpKey.Secret() - img, err := otpKey.Image(320, 240) - if err != nil { - ctx.ServerError("SettingsTwoFactor", err) - return false - } - - var imgBytes bytes.Buffer - if err = png.Encode(&imgBytes, img); err != nil { - ctx.ServerError("SettingsTwoFactor", err) - return false - } - - ctx.Data["QrUri"] = template.URL("data:image/png;base64," + base64.StdEncoding.EncodeToString(imgBytes.Bytes())) - ctx.Session.Set("twofaSecret", otpKey.Secret()) - ctx.Session.Set("twofaUri", otpKey.String()) - return true -} - -// SettingsTwoFactorEnroll shows the page where the user can enroll into 2FA. -func SettingsTwoFactorEnroll(ctx *context.Context) { - ctx.Data["Title"] = ctx.Tr("settings") - ctx.Data["PageIsSettingsSecurity"] = true - - t, err := models.GetTwoFactorByUID(ctx.User.ID) - if t != nil { - // already enrolled - ctx.ServerError("SettingsTwoFactor", err) - return - } - if err != nil && !models.IsErrTwoFactorNotEnrolled(err) { - ctx.ServerError("SettingsTwoFactor", err) - return - } - - if !twofaGenerateSecretAndQr(ctx) { - return - } - - ctx.HTML(200, tplSettingsTwofaEnroll) -} - -// SettingsTwoFactorEnrollPost handles enrolling the user into 2FA. -func SettingsTwoFactorEnrollPost(ctx *context.Context, form auth.TwoFactorAuthForm) { - ctx.Data["Title"] = ctx.Tr("settings") - ctx.Data["PageIsSettingsSecurity"] = true - - t, err := models.GetTwoFactorByUID(ctx.User.ID) - if t != nil { - // already enrolled - ctx.ServerError("SettingsTwoFactor", err) - return - } - if err != nil && !models.IsErrTwoFactorNotEnrolled(err) { - ctx.ServerError("SettingsTwoFactor", err) - return - } - - if ctx.HasError() { - if !twofaGenerateSecretAndQr(ctx) { - return - } - ctx.HTML(200, tplSettingsTwofaEnroll) - return - } - - secret := ctx.Session.Get("twofaSecret").(string) - if !totp.Validate(form.Passcode, secret) { - if !twofaGenerateSecretAndQr(ctx) { - return - } - ctx.Flash.Error(ctx.Tr("settings.passcode_invalid")) - ctx.HTML(200, tplSettingsTwofaEnroll) - return - } - - t = &models.TwoFactor{ - UID: ctx.User.ID, - } - err = t.SetSecret(secret) - if err != nil { - ctx.ServerError("SettingsTwoFactor", err) - return - } - err = t.GenerateScratchToken() - if err != nil { - ctx.ServerError("SettingsTwoFactor", err) - return - } - - if err = models.NewTwoFactor(t); err != nil { - ctx.ServerError("SettingsTwoFactor", err) - return - } - - ctx.Session.Delete("twofaSecret") - ctx.Session.Delete("twofaUri") - ctx.Flash.Success(ctx.Tr("settings.twofa_enrolled", t.ScratchToken)) - ctx.Redirect(setting.AppSubURL + "/user/settings/security") -} - -// SettingsOrganization render all the organization of the user -func SettingsOrganization(ctx *context.Context) { - ctx.Data["Title"] = ctx.Tr("settings") - ctx.Data["PageIsSettingsOrganization"] = true - orgs, err := models.GetOrgsByUserID(ctx.User.ID, ctx.IsSigned) - if err != nil { - ctx.ServerError("GetOrgsByUserID", err) - return - } - ctx.Data["Orgs"] = orgs - ctx.HTML(200, tplSettingsOrganization) -} - -// SettingsRepos display a list of all repositories of the user -func SettingsRepos(ctx *context.Context) { - ctx.Data["Title"] = ctx.Tr("settings") - ctx.Data["PageIsSettingsRepos"] = true - ctxUser := ctx.User - - var err error - if err = ctxUser.GetRepositories(1, setting.UI.User.RepoPagingNum); err != nil { - ctx.ServerError("GetRepositories", err) - return - } - repos := ctxUser.Repos - - for i := range repos { - if repos[i].IsFork { - err := repos[i].GetBaseRepo() - if err != nil { - ctx.ServerError("GetBaseRepo", err) - return - } - err = repos[i].BaseRepo.GetOwner() - if err != nil { - ctx.ServerError("GetOwner", err) - return - } - } - } - - ctx.Data["Owner"] = ctxUser - ctx.Data["Repos"] = repos - - ctx.HTML(200, tplSettingsRepositories) -} diff --git a/routers/user/setting/account.go b/routers/user/setting/account.go new file mode 100644 index 0000000000..966d96aeda --- /dev/null +++ b/routers/user/setting/account.go @@ -0,0 +1,174 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2018 The Gitea 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 setting + +import ( + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/auth" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" +) + +const ( + tplSettingsAccount base.TplName = "user/settings/account" +) + +// Account renders change user's password, user's email and user suicide page +func Account(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsAccount"] = true + ctx.Data["Email"] = ctx.User.Email + + emails, err := models.GetEmailAddresses(ctx.User.ID) + if err != nil { + ctx.ServerError("GetEmailAddresses", err) + return + } + ctx.Data["Emails"] = emails + + ctx.HTML(200, tplSettingsAccount) +} + +// AccountPost response for change user's password +func AccountPost(ctx *context.Context, form auth.ChangePasswordForm) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsAccount"] = true + + if ctx.HasError() { + ctx.HTML(200, tplSettingsAccount) + return + } + + if len(form.Password) < setting.MinPasswordLength { + ctx.Flash.Error(ctx.Tr("auth.password_too_short", setting.MinPasswordLength)) + } else if ctx.User.IsPasswordSet() && !ctx.User.ValidatePassword(form.OldPassword) { + ctx.Flash.Error(ctx.Tr("settings.password_incorrect")) + } else if form.Password != form.Retype { + ctx.Flash.Error(ctx.Tr("form.password_not_match")) + } else { + var err error + if ctx.User.Salt, err = models.GetUserSalt(); err != nil { + ctx.ServerError("UpdateUser", err) + return + } + ctx.User.HashPassword(form.Password) + if err := models.UpdateUserCols(ctx.User, "salt", "passwd"); err != nil { + ctx.ServerError("UpdateUser", err) + return + } + log.Trace("User password updated: %s", ctx.User.Name) + ctx.Flash.Success(ctx.Tr("settings.change_password_success")) + } + + ctx.Redirect(setting.AppSubURL + "/user/settings/account") +} + +// EmailPost response for change user's email +func EmailPost(ctx *context.Context, form auth.AddEmailForm) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsAccount"] = true + + // Make emailaddress primary. + if ctx.Query("_method") == "PRIMARY" { + if err := models.MakeEmailPrimary(&models.EmailAddress{ID: ctx.QueryInt64("id")}); err != nil { + ctx.ServerError("MakeEmailPrimary", err) + return + } + + log.Trace("Email made primary: %s", ctx.User.Name) + ctx.Redirect(setting.AppSubURL + "/user/settings/account") + return + } + + // Add Email address. + emails, err := models.GetEmailAddresses(ctx.User.ID) + if err != nil { + ctx.ServerError("GetEmailAddresses", err) + return + } + ctx.Data["Emails"] = emails + + if ctx.HasError() { + ctx.HTML(200, tplSettingsAccount) + return + } + + email := &models.EmailAddress{ + UID: ctx.User.ID, + Email: form.Email, + IsActivated: !setting.Service.RegisterEmailConfirm, + } + if err := models.AddEmailAddress(email); err != nil { + if models.IsErrEmailAlreadyUsed(err) { + ctx.RenderWithErr(ctx.Tr("form.email_been_used"), tplSettingsAccount, &form) + return + } + ctx.ServerError("AddEmailAddress", err) + return + } + + // Send confirmation email + if setting.Service.RegisterEmailConfirm { + models.SendActivateEmailMail(ctx.Context, ctx.User, email) + + if err := ctx.Cache.Put("MailResendLimit_"+ctx.User.LowerName, ctx.User.LowerName, 180); err != nil { + log.Error(4, "Set cache(MailResendLimit) fail: %v", err) + } + ctx.Flash.Info(ctx.Tr("settings.add_email_confirmation_sent", email.Email, base.MinutesToFriendly(setting.Service.ActiveCodeLives, ctx.Locale.Language()))) + } else { + ctx.Flash.Success(ctx.Tr("settings.add_email_success")) + } + + log.Trace("Email address added: %s", email.Email) + ctx.Redirect(setting.AppSubURL + "/user/settings/account") +} + +// DeleteEmail response for delete user's email +func DeleteEmail(ctx *context.Context) { + if err := models.DeleteEmailAddress(&models.EmailAddress{ID: ctx.QueryInt64("id"), UID: ctx.User.ID}); err != nil { + ctx.ServerError("DeleteEmail", err) + return + } + log.Trace("Email address deleted: %s", ctx.User.Name) + + ctx.Flash.Success(ctx.Tr("settings.email_deletion_success")) + ctx.JSON(200, map[string]interface{}{ + "redirect": setting.AppSubURL + "/user/settings/account", + }) +} + +// DeleteAccount render user suicide page and response for delete user himself +func DeleteAccount(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsAccount"] = true + + if _, err := models.UserSignIn(ctx.User.Name, ctx.Query("password")); err != nil { + if models.IsErrUserNotExist(err) { + ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_password"), tplSettingsAccount, nil) + } else { + ctx.ServerError("UserSignIn", err) + } + return + } + + if err := models.DeleteUser(ctx.User); err != nil { + switch { + case models.IsErrUserOwnRepos(err): + ctx.Flash.Error(ctx.Tr("form.still_own_repo")) + ctx.Redirect(setting.AppSubURL + "/user/settings/account") + case models.IsErrUserHasOrgs(err): + ctx.Flash.Error(ctx.Tr("form.still_has_org")) + ctx.Redirect(setting.AppSubURL + "/user/settings/account") + default: + ctx.ServerError("DeleteUser", err) + } + } else { + log.Trace("Account deleted: %s", ctx.User.Name) + ctx.Redirect(setting.AppSubURL + "/") + } +} diff --git a/routers/user/setting_test.go b/routers/user/setting/account_test.go similarity index 91% rename from routers/user/setting_test.go rename to routers/user/setting/account_test.go index 6aa9a07439..59fbda1569 100644 --- a/routers/user/setting_test.go +++ b/routers/user/setting/account_test.go @@ -1,8 +1,8 @@ -// Copyright 2017 The Gitea Authors. All rights reserved. +// Copyright 2018 The Gitea 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 user +package setting import ( "net/http" @@ -56,7 +56,7 @@ func TestChangePassword(t *testing.T) { test.LoadUser(t, ctx, 2) test.LoadRepo(t, ctx, 1) - SettingsAccountPost(ctx, auth.ChangePasswordForm{ + AccountPost(ctx, auth.ChangePasswordForm{ OldPassword: req.OldPassword, Password: req.NewPassword, Retype: req.Retype, diff --git a/routers/user/setting/applications.go b/routers/user/setting/applications.go new file mode 100644 index 0000000000..f292b65d70 --- /dev/null +++ b/routers/user/setting/applications.go @@ -0,0 +1,77 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2018 The Gitea 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 setting + +import ( + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/auth" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/setting" +) + +const ( + tplSettingsApplications base.TplName = "user/settings/applications" +) + +// Applications render manage access token page +func Applications(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsApplications"] = true + + tokens, err := models.ListAccessTokens(ctx.User.ID) + if err != nil { + ctx.ServerError("ListAccessTokens", err) + return + } + ctx.Data["Tokens"] = tokens + + ctx.HTML(200, tplSettingsApplications) +} + +// ApplicationsPost response for add user's access token +func ApplicationsPost(ctx *context.Context, form auth.NewAccessTokenForm) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsApplications"] = true + + if ctx.HasError() { + tokens, err := models.ListAccessTokens(ctx.User.ID) + if err != nil { + ctx.ServerError("ListAccessTokens", err) + return + } + ctx.Data["Tokens"] = tokens + ctx.HTML(200, tplSettingsApplications) + return + } + + t := &models.AccessToken{ + UID: ctx.User.ID, + Name: form.Name, + } + if err := models.NewAccessToken(t); err != nil { + ctx.ServerError("NewAccessToken", err) + return + } + + ctx.Flash.Success(ctx.Tr("settings.generate_token_success")) + ctx.Flash.Info(t.Sha1) + + ctx.Redirect(setting.AppSubURL + "/user/settings/applications") +} + +// DeleteApplication response for delete user access token +func DeleteApplication(ctx *context.Context) { + if err := models.DeleteAccessTokenByID(ctx.QueryInt64("id"), ctx.User.ID); err != nil { + ctx.Flash.Error("DeleteAccessTokenByID: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("settings.delete_token_success")) + } + + ctx.JSON(200, map[string]interface{}{ + "redirect": setting.AppSubURL + "/user/settings/applications", + }) +} diff --git a/routers/user/setting/keys.go b/routers/user/setting/keys.go new file mode 100644 index 0000000000..5c28fa6e6d --- /dev/null +++ b/routers/user/setting/keys.go @@ -0,0 +1,149 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2018 The Gitea 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 setting + +import ( + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/auth" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/setting" +) + +const ( + tplSettingsKeys base.TplName = "user/settings/keys" +) + +// Keys render user's SSH/GPG public keys page +func Keys(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsKeys"] = true + ctx.Data["DisableSSH"] = setting.SSH.Disabled + + keys, err := models.ListPublicKeys(ctx.User.ID) + if err != nil { + ctx.ServerError("ListPublicKeys", err) + return + } + ctx.Data["Keys"] = keys + + gpgkeys, err := models.ListGPGKeys(ctx.User.ID) + if err != nil { + ctx.ServerError("ListGPGKeys", err) + return + } + ctx.Data["GPGKeys"] = gpgkeys + + ctx.HTML(200, tplSettingsKeys) +} + +// KeysPost response for change user's SSH/GPG keys +func KeysPost(ctx *context.Context, form auth.AddKeyForm) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsKeys"] = true + + keys, err := models.ListPublicKeys(ctx.User.ID) + if err != nil { + ctx.ServerError("ListPublicKeys", err) + return + } + ctx.Data["Keys"] = keys + + gpgkeys, err := models.ListGPGKeys(ctx.User.ID) + if err != nil { + ctx.ServerError("ListGPGKeys", err) + return + } + ctx.Data["GPGKeys"] = gpgkeys + + if ctx.HasError() { + ctx.HTML(200, tplSettingsKeys) + return + } + switch form.Type { + case "gpg": + key, err := models.AddGPGKey(ctx.User.ID, form.Content) + if err != nil { + ctx.Data["HasGPGError"] = true + switch { + case models.IsErrGPGKeyParsing(err): + ctx.Flash.Error(ctx.Tr("form.invalid_gpg_key", err.Error())) + ctx.Redirect(setting.AppSubURL + "/user/settings/keys") + case models.IsErrGPGKeyIDAlreadyUsed(err): + ctx.Data["Err_Content"] = true + ctx.RenderWithErr(ctx.Tr("settings.gpg_key_id_used"), tplSettingsKeys, &form) + case models.IsErrGPGNoEmailFound(err): + ctx.Data["Err_Content"] = true + ctx.RenderWithErr(ctx.Tr("settings.gpg_no_key_email_found"), tplSettingsKeys, &form) + default: + ctx.ServerError("AddPublicKey", err) + } + return + } + ctx.Flash.Success(ctx.Tr("settings.add_gpg_key_success", key.KeyID)) + ctx.Redirect(setting.AppSubURL + "/user/settings/keys") + case "ssh": + content, err := models.CheckPublicKeyString(form.Content) + if err != nil { + if models.IsErrSSHDisabled(err) { + ctx.Flash.Info(ctx.Tr("settings.ssh_disabled")) + } else if models.IsErrKeyUnableVerify(err) { + ctx.Flash.Info(ctx.Tr("form.unable_verify_ssh_key")) + } else { + ctx.Flash.Error(ctx.Tr("form.invalid_ssh_key", err.Error())) + } + ctx.Redirect(setting.AppSubURL + "/user/settings/keys") + return + } + + if _, err = models.AddPublicKey(ctx.User.ID, form.Title, content); err != nil { + ctx.Data["HasSSHError"] = true + switch { + case models.IsErrKeyAlreadyExist(err): + ctx.Data["Err_Content"] = true + ctx.RenderWithErr(ctx.Tr("settings.ssh_key_been_used"), tplSettingsKeys, &form) + case models.IsErrKeyNameAlreadyUsed(err): + ctx.Data["Err_Title"] = true + ctx.RenderWithErr(ctx.Tr("settings.ssh_key_name_used"), tplSettingsKeys, &form) + default: + ctx.ServerError("AddPublicKey", err) + } + return + } + ctx.Flash.Success(ctx.Tr("settings.add_key_success", form.Title)) + ctx.Redirect(setting.AppSubURL + "/user/settings/keys") + + default: + ctx.Flash.Warning("Function not implemented") + ctx.Redirect(setting.AppSubURL + "/user/settings/keys") + } + +} + +// DeleteKey response for delete user's SSH/GPG key +func DeleteKey(ctx *context.Context) { + + switch ctx.Query("type") { + case "gpg": + if err := models.DeleteGPGKey(ctx.User, ctx.QueryInt64("id")); err != nil { + ctx.Flash.Error("DeleteGPGKey: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("settings.gpg_key_deletion_success")) + } + case "ssh": + if err := models.DeletePublicKey(ctx.User, ctx.QueryInt64("id")); err != nil { + ctx.Flash.Error("DeletePublicKey: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("settings.ssh_key_deletion_success")) + } + default: + ctx.Flash.Warning("Function not implemented") + ctx.Redirect(setting.AppSubURL + "/user/settings/keys") + } + ctx.JSON(200, map[string]interface{}{ + "redirect": setting.AppSubURL + "/user/settings/keys", + }) +} diff --git a/routers/user/setting/main_test.go b/routers/user/setting/main_test.go new file mode 100644 index 0000000000..d343c02f48 --- /dev/null +++ b/routers/user/setting/main_test.go @@ -0,0 +1,16 @@ +// Copyright 2018 The Gitea 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 setting + +import ( + "path/filepath" + "testing" + + "code.gitea.io/gitea/models" +) + +func TestMain(m *testing.M) { + models.MainTest(m, filepath.Join("..", "..", "..")) +} diff --git a/routers/user/setting/profile.go b/routers/user/setting/profile.go new file mode 100644 index 0000000000..2ca64ad2e5 --- /dev/null +++ b/routers/user/setting/profile.go @@ -0,0 +1,220 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2018 The Gitea 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 setting + +import ( + "errors" + "fmt" + "io/ioutil" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/auth" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + + "github.com/Unknwon/com" + "github.com/Unknwon/i18n" +) + +const ( + tplSettingsProfile base.TplName = "user/settings/profile" + tplSettingsOrganization base.TplName = "user/settings/organization" + tplSettingsRepositories base.TplName = "user/settings/repos" +) + +// Profile render user's profile page +func Profile(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsProfile"] = true + ctx.HTML(200, tplSettingsProfile) +} + +func handleUsernameChange(ctx *context.Context, newName string) { + // Non-local users are not allowed to change their username. + if len(newName) == 0 || !ctx.User.IsLocal() { + return + } + + // Check if user name has been changed + if ctx.User.LowerName != strings.ToLower(newName) { + if err := models.ChangeUserName(ctx.User, newName); err != nil { + switch { + case models.IsErrUserAlreadyExist(err): + ctx.Flash.Error(ctx.Tr("form.username_been_taken")) + ctx.Redirect(setting.AppSubURL + "/user/settings") + case models.IsErrEmailAlreadyUsed(err): + ctx.Flash.Error(ctx.Tr("form.email_been_used")) + ctx.Redirect(setting.AppSubURL + "/user/settings") + case models.IsErrNameReserved(err): + ctx.Flash.Error(ctx.Tr("user.form.name_reserved", newName)) + ctx.Redirect(setting.AppSubURL + "/user/settings") + case models.IsErrNamePatternNotAllowed(err): + ctx.Flash.Error(ctx.Tr("user.form.name_pattern_not_allowed", newName)) + ctx.Redirect(setting.AppSubURL + "/user/settings") + default: + ctx.ServerError("ChangeUserName", err) + } + return + } + log.Trace("User name changed: %s -> %s", ctx.User.Name, newName) + } + + // In case it's just a case change + ctx.User.Name = newName + ctx.User.LowerName = strings.ToLower(newName) +} + +// ProfilePost response for change user's profile +func ProfilePost(ctx *context.Context, form auth.UpdateProfileForm) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsProfile"] = true + + if ctx.HasError() { + ctx.HTML(200, tplSettingsProfile) + return + } + + handleUsernameChange(ctx, form.Name) + if ctx.Written() { + return + } + + ctx.User.FullName = form.FullName + ctx.User.Email = form.Email + ctx.User.KeepEmailPrivate = form.KeepEmailPrivate + ctx.User.Website = form.Website + ctx.User.Location = form.Location + ctx.User.Language = form.Language + if err := models.UpdateUserSetting(ctx.User); err != nil { + if _, ok := err.(models.ErrEmailAlreadyUsed); ok { + ctx.Flash.Error(ctx.Tr("form.email_been_used")) + ctx.Redirect(setting.AppSubURL + "/user/settings") + return + } + ctx.ServerError("UpdateUser", err) + return + } + + // Update the language to the one we just set + ctx.SetCookie("lang", ctx.User.Language, nil, setting.AppSubURL) + + log.Trace("User settings updated: %s", ctx.User.Name) + ctx.Flash.Success(i18n.Tr(ctx.User.Language, "settings.update_profile_success")) + ctx.Redirect(setting.AppSubURL + "/user/settings") +} + +// UpdateAvatarSetting update user's avatar +// FIXME: limit size. +func UpdateAvatarSetting(ctx *context.Context, form auth.AvatarForm, ctxUser *models.User) error { + ctxUser.UseCustomAvatar = form.Source == auth.AvatarLocal + if len(form.Gravatar) > 0 { + ctxUser.Avatar = base.EncodeMD5(form.Gravatar) + ctxUser.AvatarEmail = form.Gravatar + } + + if form.Avatar != nil { + fr, err := form.Avatar.Open() + if err != nil { + return fmt.Errorf("Avatar.Open: %v", err) + } + defer fr.Close() + + data, err := ioutil.ReadAll(fr) + if err != nil { + return fmt.Errorf("ioutil.ReadAll: %v", err) + } + if !base.IsImageFile(data) { + return errors.New(ctx.Tr("settings.uploaded_avatar_not_a_image")) + } + if err = ctxUser.UploadAvatar(data); err != nil { + return fmt.Errorf("UploadAvatar: %v", err) + } + } else { + // No avatar is uploaded but setting has been changed to enable, + // generate a random one when needed. + if ctxUser.UseCustomAvatar && !com.IsFile(ctxUser.CustomAvatarPath()) { + if err := ctxUser.GenerateRandomAvatar(); err != nil { + log.Error(4, "GenerateRandomAvatar[%d]: %v", ctxUser.ID, err) + } + } + } + + if err := models.UpdateUserCols(ctxUser, "avatar", "avatar_email", "use_custom_avatar"); err != nil { + return fmt.Errorf("UpdateUser: %v", err) + } + + return nil +} + +// AvatarPost response for change user's avatar request +func AvatarPost(ctx *context.Context, form auth.AvatarForm) { + if err := UpdateAvatarSetting(ctx, form, ctx.User); err != nil { + ctx.Flash.Error(err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("settings.update_avatar_success")) + } + + ctx.Redirect(setting.AppSubURL + "/user/settings") +} + +// DeleteAvatar render delete avatar page +func DeleteAvatar(ctx *context.Context) { + if err := ctx.User.DeleteAvatar(); err != nil { + ctx.Flash.Error(err.Error()) + } + + ctx.Redirect(setting.AppSubURL + "/user/settings") +} + +// Organization render all the organization of the user +func Organization(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsOrganization"] = true + orgs, err := models.GetOrgsByUserID(ctx.User.ID, ctx.IsSigned) + if err != nil { + ctx.ServerError("GetOrgsByUserID", err) + return + } + ctx.Data["Orgs"] = orgs + ctx.HTML(200, tplSettingsOrganization) +} + +// Repos display a list of all repositories of the user +func Repos(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsRepos"] = true + ctxUser := ctx.User + + var err error + if err = ctxUser.GetRepositories(1, setting.UI.User.RepoPagingNum); err != nil { + ctx.ServerError("GetRepositories", err) + return + } + repos := ctxUser.Repos + + for i := range repos { + if repos[i].IsFork { + err := repos[i].GetBaseRepo() + if err != nil { + ctx.ServerError("GetBaseRepo", err) + return + } + err = repos[i].BaseRepo.GetOwner() + if err != nil { + ctx.ServerError("GetOwner", err) + return + } + } + } + + ctx.Data["Owner"] = ctxUser + ctx.Data["Repos"] = repos + + ctx.HTML(200, tplSettingsRepositories) +} diff --git a/routers/user/setting/security.go b/routers/user/setting/security.go new file mode 100644 index 0000000000..5346f349ff --- /dev/null +++ b/routers/user/setting/security.go @@ -0,0 +1,92 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2018 The Gitea 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 setting + +import ( + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/setting" +) + +const ( + tplSettingsSecurity base.TplName = "user/settings/security" + tplSettingsTwofaEnroll base.TplName = "user/settings/twofa_enroll" +) + +// Security render change user's password page and 2FA +func Security(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsSecurity"] = true + + enrolled := true + _, err := models.GetTwoFactorByUID(ctx.User.ID) + if err != nil { + if models.IsErrTwoFactorNotEnrolled(err) { + enrolled = false + } else { + ctx.ServerError("SettingsTwoFactor", err) + return + } + } + ctx.Data["TwofaEnrolled"] = enrolled + + tokens, err := models.ListAccessTokens(ctx.User.ID) + if err != nil { + ctx.ServerError("ListAccessTokens", err) + return + } + ctx.Data["Tokens"] = tokens + + accountLinks, err := models.ListAccountLinks(ctx.User) + if err != nil { + ctx.ServerError("ListAccountLinks", err) + return + } + + // map the provider display name with the LoginSource + sources := make(map[*models.LoginSource]string) + for _, externalAccount := range accountLinks { + if loginSource, err := models.GetLoginSourceByID(externalAccount.LoginSourceID); err == nil { + var providerDisplayName string + if loginSource.IsOAuth2() { + providerTechnicalName := loginSource.OAuth2().Provider + providerDisplayName = models.OAuth2Providers[providerTechnicalName].DisplayName + } else { + providerDisplayName = loginSource.Name + } + sources[loginSource] = providerDisplayName + } + } + ctx.Data["AccountLinks"] = sources + + if ctx.Query("openid.return_to") != "" { + settingsOpenIDVerify(ctx) + return + } + + openid, err := models.GetUserOpenIDs(ctx.User.ID) + if err != nil { + ctx.ServerError("GetUserOpenIDs", err) + return + } + ctx.Data["OpenIDs"] = openid + + ctx.HTML(200, tplSettingsSecurity) +} + +// DeleteAccountLink delete a single account link +func DeleteAccountLink(ctx *context.Context) { + if _, err := models.RemoveAccountLink(ctx.User, ctx.QueryInt64("loginSourceID")); err != nil { + ctx.Flash.Error("RemoveAccountLink: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("settings.remove_account_link_success")) + } + + ctx.JSON(200, map[string]interface{}{ + "redirect": setting.AppSubURL + "/user/settings/security", + }) +} diff --git a/routers/user/setting_openid.go b/routers/user/setting/security_openid.go similarity index 94% rename from routers/user/setting_openid.go rename to routers/user/setting/security_openid.go index 7716466120..c98dc2cda9 100644 --- a/routers/user/setting_openid.go +++ b/routers/user/setting/security_openid.go @@ -1,8 +1,8 @@ -// Copyright 2017 The Gitea Authors. All rights reserved. +// Copyright 2018 The Gitea 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 user +package setting import ( "code.gitea.io/gitea/models" @@ -13,8 +13,8 @@ import ( "code.gitea.io/gitea/modules/setting" ) -// SettingsOpenIDPost response for change user's openid -func SettingsOpenIDPost(ctx *context.Context, form auth.AddOpenIDForm) { +// OpenIDPost response for change user's openid +func OpenIDPost(ctx *context.Context, form auth.AddOpenIDForm) { ctx.Data["Title"] = ctx.Tr("settings") ctx.Data["PageIsSettingsSecurity"] = true diff --git a/routers/user/setting/security_twofa.go b/routers/user/setting/security_twofa.go new file mode 100644 index 0000000000..55101ed1a4 --- /dev/null +++ b/routers/user/setting/security_twofa.go @@ -0,0 +1,187 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2018 The Gitea 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 setting + +import ( + "bytes" + "encoding/base64" + "html/template" + "image/png" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/auth" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/setting" + + "github.com/pquerna/otp" + "github.com/pquerna/otp/totp" +) + +// RegenerateScratchTwoFactor regenerates the user's 2FA scratch code. +func RegenerateScratchTwoFactor(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsSecurity"] = true + + t, err := models.GetTwoFactorByUID(ctx.User.ID) + if err != nil { + ctx.ServerError("SettingsTwoFactor", err) + return + } + + if err = t.GenerateScratchToken(); err != nil { + ctx.ServerError("SettingsTwoFactor", err) + return + } + + if err = models.UpdateTwoFactor(t); err != nil { + ctx.ServerError("SettingsTwoFactor", err) + return + } + + ctx.Flash.Success(ctx.Tr("settings.twofa_scratch_token_regenerated", t.ScratchToken)) + ctx.Redirect(setting.AppSubURL + "/user/settings/security") +} + +// DisableTwoFactor deletes the user's 2FA settings. +func DisableTwoFactor(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsSecurity"] = true + + t, err := models.GetTwoFactorByUID(ctx.User.ID) + if err != nil { + ctx.ServerError("SettingsTwoFactor", err) + return + } + + if err = models.DeleteTwoFactorByID(t.ID, ctx.User.ID); err != nil { + ctx.ServerError("SettingsTwoFactor", err) + return + } + + ctx.Flash.Success(ctx.Tr("settings.twofa_disabled")) + ctx.Redirect(setting.AppSubURL + "/user/settings/security") +} + +func twofaGenerateSecretAndQr(ctx *context.Context) bool { + var otpKey *otp.Key + var err error + uri := ctx.Session.Get("twofaUri") + if uri != nil { + otpKey, err = otp.NewKeyFromURL(uri.(string)) + } + if otpKey == nil { + err = nil // clear the error, in case the URL was invalid + otpKey, err = totp.Generate(totp.GenerateOpts{ + Issuer: setting.AppName + " (" + strings.TrimRight(setting.AppURL, "/") + ")", + AccountName: ctx.User.Name, + }) + if err != nil { + ctx.ServerError("SettingsTwoFactor", err) + return false + } + } + + ctx.Data["TwofaSecret"] = otpKey.Secret() + img, err := otpKey.Image(320, 240) + if err != nil { + ctx.ServerError("SettingsTwoFactor", err) + return false + } + + var imgBytes bytes.Buffer + if err = png.Encode(&imgBytes, img); err != nil { + ctx.ServerError("SettingsTwoFactor", err) + return false + } + + ctx.Data["QrUri"] = template.URL("data:image/png;base64," + base64.StdEncoding.EncodeToString(imgBytes.Bytes())) + ctx.Session.Set("twofaSecret", otpKey.Secret()) + ctx.Session.Set("twofaUri", otpKey.String()) + return true +} + +// EnrollTwoFactor shows the page where the user can enroll into 2FA. +func EnrollTwoFactor(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsSecurity"] = true + + t, err := models.GetTwoFactorByUID(ctx.User.ID) + if t != nil { + // already enrolled + ctx.ServerError("SettingsTwoFactor", err) + return + } + if err != nil && !models.IsErrTwoFactorNotEnrolled(err) { + ctx.ServerError("SettingsTwoFactor", err) + return + } + + if !twofaGenerateSecretAndQr(ctx) { + return + } + + ctx.HTML(200, tplSettingsTwofaEnroll) +} + +// EnrollTwoFactorPost handles enrolling the user into 2FA. +func EnrollTwoFactorPost(ctx *context.Context, form auth.TwoFactorAuthForm) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsSecurity"] = true + + t, err := models.GetTwoFactorByUID(ctx.User.ID) + if t != nil { + // already enrolled + ctx.ServerError("SettingsTwoFactor", err) + return + } + if err != nil && !models.IsErrTwoFactorNotEnrolled(err) { + ctx.ServerError("SettingsTwoFactor", err) + return + } + + if ctx.HasError() { + if !twofaGenerateSecretAndQr(ctx) { + return + } + ctx.HTML(200, tplSettingsTwofaEnroll) + return + } + + secret := ctx.Session.Get("twofaSecret").(string) + if !totp.Validate(form.Passcode, secret) { + if !twofaGenerateSecretAndQr(ctx) { + return + } + ctx.Flash.Error(ctx.Tr("settings.passcode_invalid")) + ctx.HTML(200, tplSettingsTwofaEnroll) + return + } + + t = &models.TwoFactor{ + UID: ctx.User.ID, + } + err = t.SetSecret(secret) + if err != nil { + ctx.ServerError("SettingsTwoFactor", err) + return + } + err = t.GenerateScratchToken() + if err != nil { + ctx.ServerError("SettingsTwoFactor", err) + return + } + + if err = models.NewTwoFactor(t); err != nil { + ctx.ServerError("SettingsTwoFactor", err) + return + } + + ctx.Session.Delete("twofaSecret") + ctx.Session.Delete("twofaUri") + ctx.Flash.Success(ctx.Tr("settings.twofa_enrolled", t.ScratchToken)) + ctx.Redirect(setting.AppSubURL + "/user/settings/security") +} diff --git a/templates/repo/settings/hooks.tmpl b/templates/repo/settings/webhook/base.tmpl similarity index 82% rename from templates/repo/settings/hooks.tmpl rename to templates/repo/settings/webhook/base.tmpl index 34c5cfbb6c..6f486131f7 100644 --- a/templates/repo/settings/hooks.tmpl +++ b/templates/repo/settings/webhook/base.tmpl @@ -3,7 +3,7 @@ {{template "repo/header" .}} {{template "repo/settings/navbar" .}}
- {{template "repo/settings/hook_list" .}} + {{template "repo/settings/webhook/list" .}}
{{template "base/footer" .}} diff --git a/templates/repo/settings/hook_delete_modal.tmpl b/templates/repo/settings/webhook/delete_modal.tmpl similarity index 100% rename from templates/repo/settings/hook_delete_modal.tmpl rename to templates/repo/settings/webhook/delete_modal.tmpl diff --git a/templates/repo/settings/hook_dingtalk.tmpl b/templates/repo/settings/webhook/dingtalk.tmpl similarity index 91% rename from templates/repo/settings/hook_dingtalk.tmpl rename to templates/repo/settings/webhook/dingtalk.tmpl index 37271a7db5..3e6504f651 100644 --- a/templates/repo/settings/hook_dingtalk.tmpl +++ b/templates/repo/settings/webhook/dingtalk.tmpl @@ -6,6 +6,6 @@ - {{template "repo/settings/hook_settings" .}} + {{template "repo/settings/webhook/settings" .}} {{end}} diff --git a/templates/repo/settings/hook_discord.tmpl b/templates/repo/settings/webhook/discord.tmpl similarity index 95% rename from templates/repo/settings/hook_discord.tmpl rename to templates/repo/settings/webhook/discord.tmpl index 901e7e6311..75c31efb51 100644 --- a/templates/repo/settings/hook_discord.tmpl +++ b/templates/repo/settings/webhook/discord.tmpl @@ -14,6 +14,6 @@ - {{template "repo/settings/hook_settings" .}} + {{template "repo/settings/webhook/settings" .}} {{end}} diff --git a/templates/repo/settings/hook_gitea.tmpl b/templates/repo/settings/webhook/gitea.tmpl similarity index 94% rename from templates/repo/settings/hook_gitea.tmpl rename to templates/repo/settings/webhook/gitea.tmpl index fc5e35e068..87a8813d0e 100644 --- a/templates/repo/settings/hook_gitea.tmpl +++ b/templates/repo/settings/webhook/gitea.tmpl @@ -23,6 +23,6 @@ - {{template "repo/settings/hook_settings" .}} + {{template "repo/settings/webhook/settings" .}} {{end}} diff --git a/templates/repo/settings/hook_gogs.tmpl b/templates/repo/settings/webhook/gogs.tmpl similarity index 96% rename from templates/repo/settings/hook_gogs.tmpl rename to templates/repo/settings/webhook/gogs.tmpl index 28098d14ec..649fb54aea 100644 --- a/templates/repo/settings/hook_gogs.tmpl +++ b/templates/repo/settings/webhook/gogs.tmpl @@ -23,6 +23,6 @@ - {{template "repo/settings/hook_settings" .}} + {{template "repo/settings/webhook/settings" .}} {{end}} diff --git a/templates/repo/settings/hook_history.tmpl b/templates/repo/settings/webhook/history.tmpl similarity index 100% rename from templates/repo/settings/hook_history.tmpl rename to templates/repo/settings/webhook/history.tmpl diff --git a/templates/repo/settings/hook_list.tmpl b/templates/repo/settings/webhook/list.tmpl similarity index 97% rename from templates/repo/settings/hook_list.tmpl rename to templates/repo/settings/webhook/list.tmpl index 4e61ba7a07..de6bd2c5f2 100644 --- a/templates/repo/settings/hook_list.tmpl +++ b/templates/repo/settings/webhook/list.tmpl @@ -48,4 +48,4 @@ -{{template "repo/settings/hook_delete_modal" .}} +{{template "repo/settings/webhook/delete_modal" .}} diff --git a/templates/repo/settings/hook_new.tmpl b/templates/repo/settings/webhook/new.tmpl similarity index 77% rename from templates/repo/settings/hook_new.tmpl rename to templates/repo/settings/webhook/new.tmpl index 7e3cf3c8cf..1b3d114577 100644 --- a/templates/repo/settings/hook_new.tmpl +++ b/templates/repo/settings/webhook/new.tmpl @@ -21,14 +21,14 @@
- {{template "repo/settings/hook_gitea" .}} - {{template "repo/settings/hook_gogs" .}} - {{template "repo/settings/hook_slack" .}} - {{template "repo/settings/hook_discord" .}} - {{template "repo/settings/hook_dingtalk" .}} + {{template "repo/settings/webhook/gitea" .}} + {{template "repo/settings/webhook/gogs" .}} + {{template "repo/settings/webhook/slack" .}} + {{template "repo/settings/webhook/discord" .}} + {{template "repo/settings/webhook/dingtalk" .}}
- {{template "repo/settings/hook_history" .}} + {{template "repo/settings/webhook/history" .}} {{template "base/footer" .}} diff --git a/templates/repo/settings/hook_settings.tmpl b/templates/repo/settings/webhook/settings.tmpl similarity index 63% rename from templates/repo/settings/hook_settings.tmpl rename to templates/repo/settings/webhook/settings.tmpl index 7f3406588f..f04c25a0a3 100644 --- a/templates/repo/settings/hook_settings.tmpl +++ b/templates/repo/settings/webhook/settings.tmpl @@ -32,6 +32,26 @@ + +
+
+
+ + + {{.i18n.Tr "repo.settings.event_delete_desc"}} +
+
+
+ +
+
+
+ + + {{.i18n.Tr "repo.settings.event_fork_desc"}} +
+
+
@@ -42,6 +62,26 @@
+ +
+
+
+ + + {{.i18n.Tr "repo.settings.event_issues_desc"}} +
+
+
+ +
+
+
+ + + {{.i18n.Tr "repo.settings.event_issue_comment_desc"}} +
+
+
@@ -62,6 +102,16 @@
+ +
+
+
+ + + {{.i18n.Tr "repo.settings.event_release_desc"}} +
+
+
@@ -83,4 +133,4 @@ {{end}} -{{template "repo/settings/hook_delete_modal" .}} +{{template "repo/settings/webhook/delete_modal" .}} diff --git a/templates/repo/settings/hook_slack.tmpl b/templates/repo/settings/webhook/slack.tmpl similarity index 96% rename from templates/repo/settings/hook_slack.tmpl rename to templates/repo/settings/webhook/slack.tmpl index 16e1859470..c35a679da7 100644 --- a/templates/repo/settings/hook_slack.tmpl +++ b/templates/repo/settings/webhook/slack.tmpl @@ -23,6 +23,6 @@ - {{template "repo/settings/hook_settings" .}} + {{template "repo/settings/webhook/settings" .}} {{end}} diff --git a/templates/user/settings/applications.tmpl b/templates/user/settings/applications.tmpl index f1a3e48115..d842644185 100644 --- a/templates/user/settings/applications.tmpl +++ b/templates/user/settings/applications.tmpl @@ -2,6 +2,7 @@
{{template "user/settings/navbar" .}}
+ {{template "base/alert" .}}

{{.i18n.Tr "settings.manage_access_token"}}

diff --git a/templates/user/settings/profile.tmpl b/templates/user/settings/profile.tmpl index e5bb2df011..6a654fda70 100644 --- a/templates/user/settings/profile.tmpl +++ b/templates/user/settings/profile.tmpl @@ -43,7 +43,7 @@