aboutsummaryrefslogtreecommitdiffstats
path: root/routers/web/user
diff options
context:
space:
mode:
Diffstat (limited to 'routers/web/user')
-rw-r--r--routers/web/user/auth.go1769
-rw-r--r--routers/web/user/auth_openid.go450
-rw-r--r--routers/web/user/avatar.go98
-rw-r--r--routers/web/user/home.go913
-rw-r--r--routers/web/user/home_test.go118
-rw-r--r--routers/web/user/main_test.go16
-rw-r--r--routers/web/user/notification.go192
-rw-r--r--routers/web/user/oauth.go646
-rw-r--r--routers/web/user/profile.go329
-rw-r--r--routers/web/user/setting/account.go313
-rw-r--r--routers/web/user/setting/account_test.go99
-rw-r--r--routers/web/user/setting/adopt.go64
-rw-r--r--routers/web/user/setting/applications.go106
-rw-r--r--routers/web/user/setting/keys.go226
-rw-r--r--routers/web/user/setting/main_test.go16
-rw-r--r--routers/web/user/setting/oauth2.go159
-rw-r--r--routers/web/user/setting/profile.go319
-rw-r--r--routers/web/user/setting/security.go111
-rw-r--r--routers/web/user/setting/security_openid.go129
-rw-r--r--routers/web/user/setting/security_twofa.go250
-rw-r--r--routers/web/user/setting/security_u2f.go111
-rw-r--r--routers/web/user/task.go32
22 files changed, 6466 insertions, 0 deletions
diff --git a/routers/web/user/auth.go b/routers/web/user/auth.go
new file mode 100644
index 0000000000..827b7cdef0
--- /dev/null
+++ b/routers/web/user/auth.go
@@ -0,0 +1,1769 @@
+// 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 user
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "io/ioutil"
+ "net/http"
+ "strings"
+
+ "code.gitea.io/gitea/models"
+ "code.gitea.io/gitea/modules/auth/oauth2"
+ "code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/context"
+ "code.gitea.io/gitea/modules/eventsource"
+ "code.gitea.io/gitea/modules/hcaptcha"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/password"
+ "code.gitea.io/gitea/modules/recaptcha"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/timeutil"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/modules/web/middleware"
+ "code.gitea.io/gitea/routers/utils"
+ "code.gitea.io/gitea/services/externalaccount"
+ "code.gitea.io/gitea/services/forms"
+ "code.gitea.io/gitea/services/mailer"
+
+ "github.com/markbates/goth"
+ "github.com/tstranex/u2f"
+)
+
+const (
+ // tplMustChangePassword template for updating a user's password
+ tplMustChangePassword = "user/auth/change_passwd"
+ // tplSignIn template for sign in page
+ tplSignIn base.TplName = "user/auth/signin"
+ // tplSignUp template path for sign up page
+ tplSignUp base.TplName = "user/auth/signup"
+ // TplActivate template path for activate user
+ TplActivate base.TplName = "user/auth/activate"
+ tplForgotPassword base.TplName = "user/auth/forgot_passwd"
+ tplResetPassword base.TplName = "user/auth/reset_passwd"
+ tplTwofa base.TplName = "user/auth/twofa"
+ tplTwofaScratch base.TplName = "user/auth/twofa_scratch"
+ tplLinkAccount base.TplName = "user/auth/link_account"
+ tplU2F base.TplName = "user/auth/u2f"
+)
+
+// AutoSignIn reads cookie and try to auto-login.
+func AutoSignIn(ctx *context.Context) (bool, error) {
+ if !models.HasEngine {
+ return false, nil
+ }
+
+ uname := ctx.GetCookie(setting.CookieUserName)
+ if len(uname) == 0 {
+ return false, nil
+ }
+
+ isSucceed := false
+ defer func() {
+ if !isSucceed {
+ log.Trace("auto-login cookie cleared: %s", uname)
+ ctx.DeleteCookie(setting.CookieUserName)
+ ctx.DeleteCookie(setting.CookieRememberName)
+ }
+ }()
+
+ u, err := models.GetUserByName(uname)
+ if err != nil {
+ if !models.IsErrUserNotExist(err) {
+ return false, fmt.Errorf("GetUserByName: %v", err)
+ }
+ return false, nil
+ }
+
+ if val, ok := ctx.GetSuperSecureCookie(
+ base.EncodeMD5(u.Rands+u.Passwd), setting.CookieRememberName); !ok || val != u.Name {
+ return false, nil
+ }
+
+ isSucceed = true
+
+ // Set session IDs
+ if err := ctx.Session.Set("uid", u.ID); err != nil {
+ return false, err
+ }
+ if err := ctx.Session.Set("uname", u.Name); err != nil {
+ return false, err
+ }
+ if err := ctx.Session.Release(); err != nil {
+ return false, err
+ }
+
+ middleware.DeleteCSRFCookie(ctx.Resp)
+ return true, nil
+}
+
+func checkAutoLogin(ctx *context.Context) bool {
+ // Check auto-login.
+ isSucceed, err := AutoSignIn(ctx)
+ if err != nil {
+ ctx.ServerError("AutoSignIn", err)
+ return true
+ }
+
+ redirectTo := ctx.Query("redirect_to")
+ if len(redirectTo) > 0 {
+ middleware.SetRedirectToCookie(ctx.Resp, redirectTo)
+ } else {
+ redirectTo = ctx.GetCookie("redirect_to")
+ }
+
+ if isSucceed {
+ middleware.DeleteRedirectToCookie(ctx.Resp)
+ ctx.RedirectToFirst(redirectTo, setting.AppSubURL+string(setting.LandingPageURL))
+ return true
+ }
+
+ return false
+}
+
+// SignIn render sign in page
+func SignIn(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("sign_in")
+
+ // Check auto-login.
+ if checkAutoLogin(ctx) {
+ return
+ }
+
+ orderedOAuth2Names, oauth2Providers, err := models.GetActiveOAuth2Providers()
+ if err != nil {
+ ctx.ServerError("UserSignIn", err)
+ return
+ }
+ ctx.Data["OrderedOAuth2Names"] = orderedOAuth2Names
+ ctx.Data["OAuth2Providers"] = oauth2Providers
+ ctx.Data["Title"] = ctx.Tr("sign_in")
+ ctx.Data["SignInLink"] = setting.AppSubURL + "/user/login"
+ ctx.Data["PageIsSignIn"] = true
+ ctx.Data["PageIsLogin"] = true
+ ctx.Data["EnableSSPI"] = models.IsSSPIEnabled()
+
+ ctx.HTML(http.StatusOK, tplSignIn)
+}
+
+// SignInPost response for sign in request
+func SignInPost(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("sign_in")
+
+ orderedOAuth2Names, oauth2Providers, err := models.GetActiveOAuth2Providers()
+ if err != nil {
+ ctx.ServerError("UserSignIn", err)
+ return
+ }
+ ctx.Data["OrderedOAuth2Names"] = orderedOAuth2Names
+ ctx.Data["OAuth2Providers"] = oauth2Providers
+ ctx.Data["Title"] = ctx.Tr("sign_in")
+ ctx.Data["SignInLink"] = setting.AppSubURL + "/user/login"
+ ctx.Data["PageIsSignIn"] = true
+ ctx.Data["PageIsLogin"] = true
+ ctx.Data["EnableSSPI"] = models.IsSSPIEnabled()
+
+ if ctx.HasError() {
+ ctx.HTML(http.StatusOK, tplSignIn)
+ return
+ }
+
+ form := web.GetForm(ctx).(*forms.SignInForm)
+ u, err := models.UserSignIn(form.UserName, form.Password)
+ if err != nil {
+ if models.IsErrUserNotExist(err) {
+ ctx.RenderWithErr(ctx.Tr("form.username_password_incorrect"), tplSignIn, &form)
+ log.Info("Failed authentication attempt for %s from %s: %v", form.UserName, ctx.RemoteAddr(), err)
+ } else if models.IsErrEmailAlreadyUsed(err) {
+ ctx.RenderWithErr(ctx.Tr("form.email_been_used"), tplSignIn, &form)
+ log.Info("Failed authentication attempt for %s from %s: %v", form.UserName, ctx.RemoteAddr(), err)
+ } else if models.IsErrUserProhibitLogin(err) {
+ log.Info("Failed authentication attempt for %s from %s: %v", form.UserName, ctx.RemoteAddr(), err)
+ ctx.Data["Title"] = ctx.Tr("auth.prohibit_login")
+ ctx.HTML(http.StatusOK, "user/auth/prohibit_login")
+ } else if models.IsErrUserInactive(err) {
+ if setting.Service.RegisterEmailConfirm {
+ ctx.Data["Title"] = ctx.Tr("auth.active_your_account")
+ ctx.HTML(http.StatusOK, TplActivate)
+ } else {
+ log.Info("Failed authentication attempt for %s from %s: %v", form.UserName, ctx.RemoteAddr(), err)
+ ctx.Data["Title"] = ctx.Tr("auth.prohibit_login")
+ ctx.HTML(http.StatusOK, "user/auth/prohibit_login")
+ }
+ } else {
+ ctx.ServerError("UserSignIn", err)
+ }
+ return
+ }
+ // If this user is enrolled in 2FA, we can't sign the user in just yet.
+ // Instead, redirect them to the 2FA authentication page.
+ _, err = models.GetTwoFactorByUID(u.ID)
+ if err != nil {
+ if models.IsErrTwoFactorNotEnrolled(err) {
+ handleSignIn(ctx, u, form.Remember)
+ } else {
+ ctx.ServerError("UserSignIn", err)
+ }
+ return
+ }
+
+ // User needs to use 2FA, save data and redirect to 2FA page.
+ if err := ctx.Session.Set("twofaUid", u.ID); err != nil {
+ ctx.ServerError("UserSignIn: Unable to set twofaUid in session", err)
+ return
+ }
+ if err := ctx.Session.Set("twofaRemember", form.Remember); err != nil {
+ ctx.ServerError("UserSignIn: Unable to set twofaRemember in session", err)
+ return
+ }
+ if err := ctx.Session.Release(); err != nil {
+ ctx.ServerError("UserSignIn: Unable to save session", err)
+ return
+ }
+
+ regs, err := models.GetU2FRegistrationsByUID(u.ID)
+ if err == nil && len(regs) > 0 {
+ ctx.Redirect(setting.AppSubURL + "/user/u2f")
+ return
+ }
+
+ ctx.Redirect(setting.AppSubURL + "/user/two_factor")
+}
+
+// TwoFactor shows the user a two-factor authentication page.
+func TwoFactor(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("twofa")
+
+ // Check auto-login.
+ if checkAutoLogin(ctx) {
+ return
+ }
+
+ // Ensure user is in a 2FA session.
+ if ctx.Session.Get("twofaUid") == nil {
+ ctx.ServerError("UserSignIn", errors.New("not in 2FA session"))
+ return
+ }
+
+ ctx.HTML(http.StatusOK, tplTwofa)
+}
+
+// TwoFactorPost validates a user's two-factor authentication token.
+func TwoFactorPost(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.TwoFactorAuthForm)
+ ctx.Data["Title"] = ctx.Tr("twofa")
+
+ // Ensure user is in a 2FA session.
+ idSess := ctx.Session.Get("twofaUid")
+ if idSess == nil {
+ ctx.ServerError("UserSignIn", errors.New("not in 2FA session"))
+ return
+ }
+
+ id := idSess.(int64)
+ twofa, err := models.GetTwoFactorByUID(id)
+ if err != nil {
+ ctx.ServerError("UserSignIn", err)
+ return
+ }
+
+ // Validate the passcode with the stored TOTP secret.
+ ok, err := twofa.ValidateTOTP(form.Passcode)
+ if err != nil {
+ ctx.ServerError("UserSignIn", err)
+ return
+ }
+
+ if ok && twofa.LastUsedPasscode != form.Passcode {
+ remember := ctx.Session.Get("twofaRemember").(bool)
+ u, err := models.GetUserByID(id)
+ if err != nil {
+ ctx.ServerError("UserSignIn", err)
+ return
+ }
+
+ if ctx.Session.Get("linkAccount") != nil {
+ gothUser := ctx.Session.Get("linkAccountGothUser")
+ if gothUser == nil {
+ ctx.ServerError("UserSignIn", errors.New("not in LinkAccount session"))
+ return
+ }
+
+ err = externalaccount.LinkAccountToUser(u, gothUser.(goth.User))
+ if err != nil {
+ ctx.ServerError("UserSignIn", err)
+ return
+ }
+ }
+
+ twofa.LastUsedPasscode = form.Passcode
+ if err = models.UpdateTwoFactor(twofa); err != nil {
+ ctx.ServerError("UserSignIn", err)
+ return
+ }
+
+ handleSignIn(ctx, u, remember)
+ return
+ }
+
+ ctx.RenderWithErr(ctx.Tr("auth.twofa_passcode_incorrect"), tplTwofa, forms.TwoFactorAuthForm{})
+}
+
+// TwoFactorScratch shows the scratch code form for two-factor authentication.
+func TwoFactorScratch(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("twofa_scratch")
+
+ // Check auto-login.
+ if checkAutoLogin(ctx) {
+ return
+ }
+
+ // Ensure user is in a 2FA session.
+ if ctx.Session.Get("twofaUid") == nil {
+ ctx.ServerError("UserSignIn", errors.New("not in 2FA session"))
+ return
+ }
+
+ ctx.HTML(http.StatusOK, tplTwofaScratch)
+}
+
+// TwoFactorScratchPost validates and invalidates a user's two-factor scratch token.
+func TwoFactorScratchPost(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.TwoFactorScratchAuthForm)
+ ctx.Data["Title"] = ctx.Tr("twofa_scratch")
+
+ // Ensure user is in a 2FA session.
+ idSess := ctx.Session.Get("twofaUid")
+ if idSess == nil {
+ ctx.ServerError("UserSignIn", errors.New("not in 2FA session"))
+ return
+ }
+
+ id := idSess.(int64)
+ twofa, err := models.GetTwoFactorByUID(id)
+ if err != nil {
+ ctx.ServerError("UserSignIn", err)
+ return
+ }
+
+ // Validate the passcode with the stored TOTP secret.
+ if twofa.VerifyScratchToken(form.Token) {
+ // Invalidate the scratch token.
+ _, err = twofa.GenerateScratchToken()
+ if err != nil {
+ ctx.ServerError("UserSignIn", err)
+ return
+ }
+ if err = models.UpdateTwoFactor(twofa); err != nil {
+ ctx.ServerError("UserSignIn", err)
+ return
+ }
+
+ remember := ctx.Session.Get("twofaRemember").(bool)
+ u, err := models.GetUserByID(id)
+ if err != nil {
+ ctx.ServerError("UserSignIn", err)
+ return
+ }
+
+ handleSignInFull(ctx, u, remember, false)
+ ctx.Flash.Info(ctx.Tr("auth.twofa_scratch_used"))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/security")
+ return
+ }
+
+ ctx.RenderWithErr(ctx.Tr("auth.twofa_scratch_token_incorrect"), tplTwofaScratch, forms.TwoFactorScratchAuthForm{})
+}
+
+// U2F shows the U2F login page
+func U2F(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("twofa")
+ ctx.Data["RequireU2F"] = true
+ // Check auto-login.
+ if checkAutoLogin(ctx) {
+ return
+ }
+
+ // Ensure user is in a 2FA session.
+ if ctx.Session.Get("twofaUid") == nil {
+ ctx.ServerError("UserSignIn", errors.New("not in U2F session"))
+ return
+ }
+
+ ctx.HTML(http.StatusOK, tplU2F)
+}
+
+// U2FChallenge submits a sign challenge to the browser
+func U2FChallenge(ctx *context.Context) {
+ // Ensure user is in a U2F session.
+ idSess := ctx.Session.Get("twofaUid")
+ if idSess == nil {
+ ctx.ServerError("UserSignIn", errors.New("not in U2F session"))
+ return
+ }
+ id := idSess.(int64)
+ regs, err := models.GetU2FRegistrationsByUID(id)
+ if err != nil {
+ ctx.ServerError("UserSignIn", err)
+ return
+ }
+ if len(regs) == 0 {
+ ctx.ServerError("UserSignIn", errors.New("no device registered"))
+ return
+ }
+ challenge, err := u2f.NewChallenge(setting.U2F.AppID, setting.U2F.TrustedFacets)
+ if err != nil {
+ ctx.ServerError("u2f.NewChallenge", err)
+ return
+ }
+ if err := ctx.Session.Set("u2fChallenge", challenge); err != nil {
+ ctx.ServerError("UserSignIn: unable to set u2fChallenge in session", err)
+ return
+ }
+ if err := ctx.Session.Release(); err != nil {
+ ctx.ServerError("UserSignIn: unable to store session", err)
+ }
+
+ ctx.JSON(http.StatusOK, challenge.SignRequest(regs.ToRegistrations()))
+}
+
+// U2FSign authenticates the user by signResp
+func U2FSign(ctx *context.Context) {
+ signResp := web.GetForm(ctx).(*u2f.SignResponse)
+ challSess := ctx.Session.Get("u2fChallenge")
+ idSess := ctx.Session.Get("twofaUid")
+ if challSess == nil || idSess == nil {
+ ctx.ServerError("UserSignIn", errors.New("not in U2F session"))
+ return
+ }
+ challenge := challSess.(*u2f.Challenge)
+ id := idSess.(int64)
+ regs, err := models.GetU2FRegistrationsByUID(id)
+ if err != nil {
+ ctx.ServerError("UserSignIn", err)
+ return
+ }
+ for _, reg := range regs {
+ r, err := reg.Parse()
+ if err != nil {
+ log.Fatal("parsing u2f registration: %v", err)
+ continue
+ }
+ newCounter, authErr := r.Authenticate(*signResp, *challenge, reg.Counter)
+ if authErr == nil {
+ reg.Counter = newCounter
+ user, err := models.GetUserByID(id)
+ if err != nil {
+ ctx.ServerError("UserSignIn", err)
+ return
+ }
+ remember := ctx.Session.Get("twofaRemember").(bool)
+ if err := reg.UpdateCounter(); err != nil {
+ ctx.ServerError("UserSignIn", err)
+ return
+ }
+
+ if ctx.Session.Get("linkAccount") != nil {
+ gothUser := ctx.Session.Get("linkAccountGothUser")
+ if gothUser == nil {
+ ctx.ServerError("UserSignIn", errors.New("not in LinkAccount session"))
+ return
+ }
+
+ err = externalaccount.LinkAccountToUser(user, gothUser.(goth.User))
+ if err != nil {
+ ctx.ServerError("UserSignIn", err)
+ return
+ }
+ }
+ redirect := handleSignInFull(ctx, user, remember, false)
+ if redirect == "" {
+ redirect = setting.AppSubURL + "/"
+ }
+ ctx.PlainText(200, []byte(redirect))
+ return
+ }
+ }
+ ctx.Error(http.StatusUnauthorized)
+}
+
+// This handles the final part of the sign-in process of the user.
+func handleSignIn(ctx *context.Context, u *models.User, remember bool) {
+ handleSignInFull(ctx, u, remember, true)
+}
+
+func handleSignInFull(ctx *context.Context, u *models.User, remember bool, obeyRedirect bool) string {
+ if remember {
+ days := 86400 * setting.LogInRememberDays
+ ctx.SetCookie(setting.CookieUserName, u.Name, days)
+ ctx.SetSuperSecureCookie(base.EncodeMD5(u.Rands+u.Passwd),
+ setting.CookieRememberName, u.Name, days)
+ }
+
+ _ = ctx.Session.Delete("openid_verified_uri")
+ _ = ctx.Session.Delete("openid_signin_remember")
+ _ = ctx.Session.Delete("openid_determined_email")
+ _ = ctx.Session.Delete("openid_determined_username")
+ _ = ctx.Session.Delete("twofaUid")
+ _ = ctx.Session.Delete("twofaRemember")
+ _ = ctx.Session.Delete("u2fChallenge")
+ _ = ctx.Session.Delete("linkAccount")
+ if err := ctx.Session.Set("uid", u.ID); err != nil {
+ log.Error("Error setting uid %d in session: %v", u.ID, err)
+ }
+ if err := ctx.Session.Set("uname", u.Name); err != nil {
+ log.Error("Error setting uname %s session: %v", u.Name, err)
+ }
+ if err := ctx.Session.Release(); err != nil {
+ log.Error("Unable to store session: %v", err)
+ }
+
+ // Language setting of the user overwrites the one previously set
+ // If the user does not have a locale set, we save the current one.
+ if len(u.Language) == 0 {
+ u.Language = ctx.Locale.Language()
+ if err := models.UpdateUserCols(u, "language"); err != nil {
+ log.Error(fmt.Sprintf("Error updating user language [user: %d, locale: %s]", u.ID, u.Language))
+ return setting.AppSubURL + "/"
+ }
+ }
+
+ middleware.SetLocaleCookie(ctx.Resp, u.Language, 0)
+
+ // Clear whatever CSRF has right now, force to generate a new one
+ middleware.DeleteCSRFCookie(ctx.Resp)
+
+ // Register last login
+ u.SetLastLogin()
+ if err := models.UpdateUserCols(u, "last_login_unix"); err != nil {
+ ctx.ServerError("UpdateUserCols", err)
+ return setting.AppSubURL + "/"
+ }
+
+ if redirectTo := ctx.GetCookie("redirect_to"); len(redirectTo) > 0 && !utils.IsExternalURL(redirectTo) {
+ middleware.DeleteRedirectToCookie(ctx.Resp)
+ if obeyRedirect {
+ ctx.RedirectToFirst(redirectTo)
+ }
+ return redirectTo
+ }
+
+ if obeyRedirect {
+ ctx.Redirect(setting.AppSubURL + "/")
+ }
+ return setting.AppSubURL + "/"
+}
+
+// SignInOAuth handles the OAuth2 login buttons
+func SignInOAuth(ctx *context.Context) {
+ provider := ctx.Params(":provider")
+
+ loginSource, err := models.GetActiveOAuth2LoginSourceByName(provider)
+ if err != nil {
+ ctx.ServerError("SignIn", err)
+ return
+ }
+
+ // try to do a direct callback flow, so we don't authenticate the user again but use the valid accesstoken to get the user
+ user, gothUser, err := oAuth2UserLoginCallback(loginSource, ctx.Req, ctx.Resp)
+ if err == nil && user != nil {
+ // we got the user without going through the whole OAuth2 authentication flow again
+ handleOAuth2SignIn(ctx, user, gothUser)
+ return
+ }
+
+ if err = oauth2.Auth(loginSource.Name, ctx.Req, ctx.Resp); err != nil {
+ if strings.Contains(err.Error(), "no provider for ") {
+ if err = models.ResetOAuth2(); err != nil {
+ ctx.ServerError("SignIn", err)
+ return
+ }
+ if err = oauth2.Auth(loginSource.Name, ctx.Req, ctx.Resp); err != nil {
+ ctx.ServerError("SignIn", err)
+ }
+ return
+ }
+ ctx.ServerError("SignIn", err)
+ }
+ // redirect is done in oauth2.Auth
+}
+
+// SignInOAuthCallback handles the callback from the given provider
+func SignInOAuthCallback(ctx *context.Context) {
+ provider := ctx.Params(":provider")
+
+ // first look if the provider is still active
+ loginSource, err := models.GetActiveOAuth2LoginSourceByName(provider)
+ if err != nil {
+ ctx.ServerError("SignIn", err)
+ return
+ }
+
+ if loginSource == nil {
+ ctx.ServerError("SignIn", errors.New("No valid provider found, check configured callback url in provider"))
+ return
+ }
+
+ u, gothUser, err := oAuth2UserLoginCallback(loginSource, ctx.Req, ctx.Resp)
+
+ if err != nil {
+ ctx.ServerError("UserSignIn", err)
+ return
+ }
+
+ if u == nil {
+ if !(setting.Service.DisableRegistration || setting.Service.AllowOnlyInternalRegistration) && setting.OAuth2Client.EnableAutoRegistration {
+ // create new user with details from oauth2 provider
+ var missingFields []string
+ if gothUser.UserID == "" {
+ missingFields = append(missingFields, "sub")
+ }
+ if gothUser.Email == "" {
+ missingFields = append(missingFields, "email")
+ }
+ if setting.OAuth2Client.Username == setting.OAuth2UsernameNickname && gothUser.NickName == "" {
+ missingFields = append(missingFields, "nickname")
+ }
+ if len(missingFields) > 0 {
+ log.Error("OAuth2 Provider %s returned empty or missing fields: %s", loginSource.Name, missingFields)
+ if loginSource.IsOAuth2() && loginSource.OAuth2().Provider == "openidConnect" {
+ log.Error("You may need to change the 'OPENID_CONNECT_SCOPES' setting to request all required fields")
+ }
+ err = fmt.Errorf("OAuth2 Provider %s returned empty or missing fields: %s", loginSource.Name, missingFields)
+ ctx.ServerError("CreateUser", err)
+ return
+ }
+ u = &models.User{
+ Name: getUserName(&gothUser),
+ FullName: gothUser.Name,
+ Email: gothUser.Email,
+ IsActive: !setting.OAuth2Client.RegisterEmailConfirm,
+ LoginType: models.LoginOAuth2,
+ LoginSource: loginSource.ID,
+ LoginName: gothUser.UserID,
+ }
+
+ if !createAndHandleCreatedUser(ctx, base.TplName(""), nil, u, &gothUser, setting.OAuth2Client.AccountLinking != setting.OAuth2AccountLinkingDisabled) {
+ // error already handled
+ return
+ }
+ } else {
+ // no existing user is found, request attach or new account
+ showLinkingLogin(ctx, gothUser)
+ return
+ }
+ }
+
+ handleOAuth2SignIn(ctx, u, gothUser)
+}
+
+func getUserName(gothUser *goth.User) string {
+ switch setting.OAuth2Client.Username {
+ case setting.OAuth2UsernameEmail:
+ return strings.Split(gothUser.Email, "@")[0]
+ case setting.OAuth2UsernameNickname:
+ return gothUser.NickName
+ default: // OAuth2UsernameUserid
+ return gothUser.UserID
+ }
+}
+
+func showLinkingLogin(ctx *context.Context, gothUser goth.User) {
+ if err := ctx.Session.Set("linkAccountGothUser", gothUser); err != nil {
+ log.Error("Error setting linkAccountGothUser in session: %v", err)
+ }
+ if err := ctx.Session.Release(); err != nil {
+ log.Error("Error storing session: %v", err)
+ }
+ ctx.Redirect(setting.AppSubURL + "/user/link_account")
+}
+
+func updateAvatarIfNeed(url string, u *models.User) {
+ if setting.OAuth2Client.UpdateAvatar && len(url) > 0 {
+ resp, err := http.Get(url)
+ if err == nil {
+ defer func() {
+ _ = resp.Body.Close()
+ }()
+ }
+ // ignore any error
+ if err == nil && resp.StatusCode == http.StatusOK {
+ data, err := ioutil.ReadAll(io.LimitReader(resp.Body, setting.Avatar.MaxFileSize+1))
+ if err == nil && int64(len(data)) <= setting.Avatar.MaxFileSize {
+ _ = u.UploadAvatar(data)
+ }
+ }
+ }
+}
+
+func handleOAuth2SignIn(ctx *context.Context, u *models.User, gothUser goth.User) {
+ updateAvatarIfNeed(gothUser.AvatarURL, u)
+
+ // If this user is enrolled in 2FA, we can't sign the user in just yet.
+ // Instead, redirect them to the 2FA authentication page.
+ _, err := models.GetTwoFactorByUID(u.ID)
+ if err != nil {
+ if !models.IsErrTwoFactorNotEnrolled(err) {
+ ctx.ServerError("UserSignIn", err)
+ return
+ }
+
+ if err := ctx.Session.Set("uid", u.ID); err != nil {
+ log.Error("Error setting uid in session: %v", err)
+ }
+ if err := ctx.Session.Set("uname", u.Name); err != nil {
+ log.Error("Error setting uname in session: %v", err)
+ }
+ if err := ctx.Session.Release(); err != nil {
+ log.Error("Error storing session: %v", err)
+ }
+
+ // Clear whatever CSRF has right now, force to generate a new one
+ middleware.DeleteCSRFCookie(ctx.Resp)
+
+ // Register last login
+ u.SetLastLogin()
+ if err := models.UpdateUserCols(u, "last_login_unix"); err != nil {
+ ctx.ServerError("UpdateUserCols", err)
+ return
+ }
+
+ // update external user information
+ if err := models.UpdateExternalUser(u, gothUser); err != nil {
+ log.Error("UpdateExternalUser failed: %v", err)
+ }
+
+ if redirectTo := ctx.GetCookie("redirect_to"); len(redirectTo) > 0 {
+ middleware.DeleteRedirectToCookie(ctx.Resp)
+ ctx.RedirectToFirst(redirectTo)
+ return
+ }
+
+ ctx.Redirect(setting.AppSubURL + "/")
+ return
+ }
+
+ // User needs to use 2FA, save data and redirect to 2FA page.
+ if err := ctx.Session.Set("twofaUid", u.ID); err != nil {
+ log.Error("Error setting twofaUid in session: %v", err)
+ }
+ if err := ctx.Session.Set("twofaRemember", false); err != nil {
+ log.Error("Error setting twofaRemember in session: %v", err)
+ }
+ if err := ctx.Session.Release(); err != nil {
+ log.Error("Error storing session: %v", err)
+ }
+
+ // If U2F is enrolled -> Redirect to U2F instead
+ regs, err := models.GetU2FRegistrationsByUID(u.ID)
+ if err == nil && len(regs) > 0 {
+ ctx.Redirect(setting.AppSubURL + "/user/u2f")
+ return
+ }
+
+ ctx.Redirect(setting.AppSubURL + "/user/two_factor")
+}
+
+// OAuth2UserLoginCallback attempts to handle the callback from the OAuth2 provider and if successful
+// login the user
+func oAuth2UserLoginCallback(loginSource *models.LoginSource, request *http.Request, response http.ResponseWriter) (*models.User, goth.User, error) {
+ gothUser, err := oauth2.ProviderCallback(loginSource.Name, request, response)
+
+ if err != nil {
+ if err.Error() == "securecookie: the value is too long" {
+ log.Error("OAuth2 Provider %s returned too long a token. Current max: %d. Either increase the [OAuth2] MAX_TOKEN_LENGTH or reduce the information returned from the OAuth2 provider", loginSource.Name, setting.OAuth2.MaxTokenLength)
+ err = fmt.Errorf("OAuth2 Provider %s returned too long a token. Current max: %d. Either increase the [OAuth2] MAX_TOKEN_LENGTH or reduce the information returned from the OAuth2 provider", loginSource.Name, setting.OAuth2.MaxTokenLength)
+ }
+ return nil, goth.User{}, err
+ }
+
+ user := &models.User{
+ LoginName: gothUser.UserID,
+ LoginType: models.LoginOAuth2,
+ LoginSource: loginSource.ID,
+ }
+
+ hasUser, err := models.GetUser(user)
+ if err != nil {
+ return nil, goth.User{}, err
+ }
+
+ if hasUser {
+ return user, gothUser, nil
+ }
+
+ // search in external linked users
+ externalLoginUser := &models.ExternalLoginUser{
+ ExternalID: gothUser.UserID,
+ LoginSourceID: loginSource.ID,
+ }
+ hasUser, err = models.GetExternalLogin(externalLoginUser)
+ if err != nil {
+ return nil, goth.User{}, err
+ }
+ if hasUser {
+ user, err = models.GetUserByID(externalLoginUser.UserID)
+ return user, gothUser, err
+ }
+
+ // no user found to login
+ return nil, gothUser, nil
+
+}
+
+// LinkAccount shows the page where the user can decide to login or create a new account
+func LinkAccount(ctx *context.Context) {
+ ctx.Data["DisablePassword"] = !setting.Service.RequireExternalRegistrationPassword || setting.Service.AllowOnlyExternalRegistration
+ ctx.Data["Title"] = ctx.Tr("link_account")
+ ctx.Data["LinkAccountMode"] = true
+ ctx.Data["EnableCaptcha"] = setting.Service.EnableCaptcha && setting.Service.RequireExternalRegistrationCaptcha
+ ctx.Data["Captcha"] = context.GetImageCaptcha()
+ ctx.Data["CaptchaType"] = setting.Service.CaptchaType
+ ctx.Data["RecaptchaURL"] = setting.Service.RecaptchaURL
+ ctx.Data["RecaptchaSitekey"] = setting.Service.RecaptchaSitekey
+ ctx.Data["HcaptchaSitekey"] = setting.Service.HcaptchaSitekey
+ ctx.Data["DisableRegistration"] = setting.Service.DisableRegistration
+ ctx.Data["AllowOnlyInternalRegistration"] = setting.Service.AllowOnlyInternalRegistration
+ ctx.Data["ShowRegistrationButton"] = false
+
+ // use this to set the right link into the signIn and signUp templates in the link_account template
+ ctx.Data["SignInLink"] = setting.AppSubURL + "/user/link_account_signin"
+ ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/link_account_signup"
+
+ gothUser := ctx.Session.Get("linkAccountGothUser")
+ if gothUser == nil {
+ ctx.ServerError("UserSignIn", errors.New("not in LinkAccount session"))
+ return
+ }
+
+ gu, _ := gothUser.(goth.User)
+ uname := getUserName(&gu)
+ email := gu.Email
+ ctx.Data["user_name"] = uname
+ ctx.Data["email"] = email
+
+ if len(email) != 0 {
+ u, err := models.GetUserByEmail(email)
+ if err != nil && !models.IsErrUserNotExist(err) {
+ ctx.ServerError("UserSignIn", err)
+ return
+ }
+ if u != nil {
+ ctx.Data["user_exists"] = true
+ }
+ } else if len(uname) != 0 {
+ u, err := models.GetUserByName(uname)
+ if err != nil && !models.IsErrUserNotExist(err) {
+ ctx.ServerError("UserSignIn", err)
+ return
+ }
+ if u != nil {
+ ctx.Data["user_exists"] = true
+ }
+ }
+
+ ctx.HTML(http.StatusOK, tplLinkAccount)
+}
+
+// LinkAccountPostSignIn handle the coupling of external account with another account using signIn
+func LinkAccountPostSignIn(ctx *context.Context) {
+ signInForm := web.GetForm(ctx).(*forms.SignInForm)
+ ctx.Data["DisablePassword"] = !setting.Service.RequireExternalRegistrationPassword || setting.Service.AllowOnlyExternalRegistration
+ ctx.Data["Title"] = ctx.Tr("link_account")
+ ctx.Data["LinkAccountMode"] = true
+ ctx.Data["LinkAccountModeSignIn"] = true
+ ctx.Data["EnableCaptcha"] = setting.Service.EnableCaptcha && setting.Service.RequireExternalRegistrationCaptcha
+ ctx.Data["RecaptchaURL"] = setting.Service.RecaptchaURL
+ ctx.Data["Captcha"] = context.GetImageCaptcha()
+ ctx.Data["CaptchaType"] = setting.Service.CaptchaType
+ ctx.Data["RecaptchaSitekey"] = setting.Service.RecaptchaSitekey
+ ctx.Data["HcaptchaSitekey"] = setting.Service.HcaptchaSitekey
+ ctx.Data["DisableRegistration"] = setting.Service.DisableRegistration
+ ctx.Data["ShowRegistrationButton"] = false
+
+ // use this to set the right link into the signIn and signUp templates in the link_account template
+ ctx.Data["SignInLink"] = setting.AppSubURL + "/user/link_account_signin"
+ ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/link_account_signup"
+
+ gothUser := ctx.Session.Get("linkAccountGothUser")
+ if gothUser == nil {
+ ctx.ServerError("UserSignIn", errors.New("not in LinkAccount session"))
+ return
+ }
+
+ if ctx.HasError() {
+ ctx.HTML(http.StatusOK, tplLinkAccount)
+ return
+ }
+
+ u, err := models.UserSignIn(signInForm.UserName, signInForm.Password)
+ if err != nil {
+ if models.IsErrUserNotExist(err) {
+ ctx.Data["user_exists"] = true
+ ctx.RenderWithErr(ctx.Tr("form.username_password_incorrect"), tplLinkAccount, &signInForm)
+ } else {
+ ctx.ServerError("UserLinkAccount", err)
+ }
+ return
+ }
+
+ linkAccount(ctx, u, gothUser.(goth.User), signInForm.Remember)
+}
+
+func linkAccount(ctx *context.Context, u *models.User, gothUser goth.User, remember bool) {
+ updateAvatarIfNeed(gothUser.AvatarURL, u)
+
+ // If this user is enrolled in 2FA, we can't sign the user in just yet.
+ // Instead, redirect them to the 2FA authentication page.
+ _, err := models.GetTwoFactorByUID(u.ID)
+ if err != nil {
+ if !models.IsErrTwoFactorNotEnrolled(err) {
+ ctx.ServerError("UserLinkAccount", err)
+ return
+ }
+
+ err = externalaccount.LinkAccountToUser(u, gothUser)
+ if err != nil {
+ ctx.ServerError("UserLinkAccount", err)
+ return
+ }
+
+ handleSignIn(ctx, u, remember)
+ return
+ }
+
+ // User needs to use 2FA, save data and redirect to 2FA page.
+ if err := ctx.Session.Set("twofaUid", u.ID); err != nil {
+ log.Error("Error setting twofaUid in session: %v", err)
+ }
+ if err := ctx.Session.Set("twofaRemember", remember); err != nil {
+ log.Error("Error setting twofaRemember in session: %v", err)
+ }
+ if err := ctx.Session.Set("linkAccount", true); err != nil {
+ log.Error("Error setting linkAccount in session: %v", err)
+ }
+ if err := ctx.Session.Release(); err != nil {
+ log.Error("Error storing session: %v", err)
+ }
+
+ // If U2F is enrolled -> Redirect to U2F instead
+ regs, err := models.GetU2FRegistrationsByUID(u.ID)
+ if err == nil && len(regs) > 0 {
+ ctx.Redirect(setting.AppSubURL + "/user/u2f")
+ return
+ }
+
+ ctx.Redirect(setting.AppSubURL + "/user/two_factor")
+}
+
+// LinkAccountPostRegister handle the creation of a new account for an external account using signUp
+func LinkAccountPostRegister(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.RegisterForm)
+ // TODO Make insecure passwords optional for local accounts also,
+ // once email-based Second-Factor Auth is available
+ ctx.Data["DisablePassword"] = !setting.Service.RequireExternalRegistrationPassword || setting.Service.AllowOnlyExternalRegistration
+ ctx.Data["Title"] = ctx.Tr("link_account")
+ ctx.Data["LinkAccountMode"] = true
+ ctx.Data["LinkAccountModeRegister"] = true
+ ctx.Data["EnableCaptcha"] = setting.Service.EnableCaptcha && setting.Service.RequireExternalRegistrationCaptcha
+ ctx.Data["RecaptchaURL"] = setting.Service.RecaptchaURL
+ ctx.Data["Captcha"] = context.GetImageCaptcha()
+ ctx.Data["CaptchaType"] = setting.Service.CaptchaType
+ ctx.Data["RecaptchaSitekey"] = setting.Service.RecaptchaSitekey
+ ctx.Data["HcaptchaSitekey"] = setting.Service.HcaptchaSitekey
+ ctx.Data["DisableRegistration"] = setting.Service.DisableRegistration
+ ctx.Data["ShowRegistrationButton"] = false
+
+ // use this to set the right link into the signIn and signUp templates in the link_account template
+ ctx.Data["SignInLink"] = setting.AppSubURL + "/user/link_account_signin"
+ ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/link_account_signup"
+
+ gothUserInterface := ctx.Session.Get("linkAccountGothUser")
+ if gothUserInterface == nil {
+ ctx.ServerError("UserSignUp", errors.New("not in LinkAccount session"))
+ return
+ }
+ gothUser, ok := gothUserInterface.(goth.User)
+ if !ok {
+ ctx.ServerError("UserSignUp", fmt.Errorf("session linkAccountGothUser type is %t but not goth.User", gothUserInterface))
+ return
+ }
+
+ if ctx.HasError() {
+ ctx.HTML(http.StatusOK, tplLinkAccount)
+ return
+ }
+
+ if setting.Service.DisableRegistration || setting.Service.AllowOnlyInternalRegistration {
+ ctx.Error(http.StatusForbidden)
+ return
+ }
+
+ if setting.Service.EnableCaptcha && setting.Service.RequireExternalRegistrationCaptcha {
+ var valid bool
+ var err error
+ switch setting.Service.CaptchaType {
+ case setting.ImageCaptcha:
+ valid = context.GetImageCaptcha().VerifyReq(ctx.Req)
+ case setting.ReCaptcha:
+ valid, err = recaptcha.Verify(ctx, form.GRecaptchaResponse)
+ case setting.HCaptcha:
+ valid, err = hcaptcha.Verify(ctx, form.HcaptchaResponse)
+ default:
+ ctx.ServerError("Unknown Captcha Type", fmt.Errorf("Unknown Captcha Type: %s", setting.Service.CaptchaType))
+ return
+ }
+ if err != nil {
+ log.Debug("%s", err.Error())
+ }
+
+ if !valid {
+ ctx.Data["Err_Captcha"] = true
+ ctx.RenderWithErr(ctx.Tr("form.captcha_incorrect"), tplLinkAccount, &form)
+ return
+ }
+ }
+
+ if !form.IsEmailDomainAllowed() {
+ ctx.RenderWithErr(ctx.Tr("auth.email_domain_blacklisted"), tplLinkAccount, &form)
+ return
+ }
+
+ if setting.Service.AllowOnlyExternalRegistration || !setting.Service.RequireExternalRegistrationPassword {
+ // In models.User an empty password is classed as not set, so we set form.Password to empty.
+ // Eventually the database should be changed to indicate "Second Factor"-enabled accounts
+ // (accounts that do not introduce the security vulnerabilities of a password).
+ // If a user decides to circumvent second-factor security, and purposefully create a password,
+ // they can still do so using the "Recover Account" option.
+ form.Password = ""
+ } else {
+ if (len(strings.TrimSpace(form.Password)) > 0 || len(strings.TrimSpace(form.Retype)) > 0) && form.Password != form.Retype {
+ ctx.Data["Err_Password"] = true
+ ctx.RenderWithErr(ctx.Tr("form.password_not_match"), tplLinkAccount, &form)
+ return
+ }
+ if len(strings.TrimSpace(form.Password)) > 0 && len(form.Password) < setting.MinPasswordLength {
+ ctx.Data["Err_Password"] = true
+ ctx.RenderWithErr(ctx.Tr("auth.password_too_short", setting.MinPasswordLength), tplLinkAccount, &form)
+ return
+ }
+ }
+
+ loginSource, err := models.GetActiveOAuth2LoginSourceByName(gothUser.Provider)
+ if err != nil {
+ ctx.ServerError("CreateUser", err)
+ }
+
+ u := &models.User{
+ Name: form.UserName,
+ Email: form.Email,
+ Passwd: form.Password,
+ IsActive: !(setting.Service.RegisterEmailConfirm || setting.Service.RegisterManualConfirm),
+ LoginType: models.LoginOAuth2,
+ LoginSource: loginSource.ID,
+ LoginName: gothUser.UserID,
+ }
+
+ if !createAndHandleCreatedUser(ctx, tplLinkAccount, form, u, &gothUser, false) {
+ // error already handled
+ return
+ }
+
+ ctx.Redirect(setting.AppSubURL + "/user/login")
+}
+
+// HandleSignOut resets the session and sets the cookies
+func HandleSignOut(ctx *context.Context) {
+ _ = ctx.Session.Flush()
+ _ = ctx.Session.Destroy(ctx.Resp, ctx.Req)
+ ctx.DeleteCookie(setting.CookieUserName)
+ ctx.DeleteCookie(setting.CookieRememberName)
+ middleware.DeleteCSRFCookie(ctx.Resp)
+ middleware.DeleteLocaleCookie(ctx.Resp)
+ middleware.DeleteRedirectToCookie(ctx.Resp)
+}
+
+// SignOut sign out from login status
+func SignOut(ctx *context.Context) {
+ if ctx.User != nil {
+ eventsource.GetManager().SendMessageBlocking(ctx.User.ID, &eventsource.Event{
+ Name: "logout",
+ Data: ctx.Session.ID(),
+ })
+ }
+ HandleSignOut(ctx)
+ ctx.Redirect(setting.AppSubURL + "/")
+}
+
+// SignUp render the register page
+func SignUp(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("sign_up")
+
+ ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/sign_up"
+
+ ctx.Data["EnableCaptcha"] = setting.Service.EnableCaptcha
+ ctx.Data["RecaptchaURL"] = setting.Service.RecaptchaURL
+ ctx.Data["Captcha"] = context.GetImageCaptcha()
+ ctx.Data["CaptchaType"] = setting.Service.CaptchaType
+ ctx.Data["RecaptchaSitekey"] = setting.Service.RecaptchaSitekey
+ ctx.Data["HcaptchaSitekey"] = setting.Service.HcaptchaSitekey
+ ctx.Data["PageIsSignUp"] = true
+
+ //Show Disabled Registration message if DisableRegistration or AllowOnlyExternalRegistration options are true
+ ctx.Data["DisableRegistration"] = setting.Service.DisableRegistration || setting.Service.AllowOnlyExternalRegistration
+
+ ctx.HTML(http.StatusOK, tplSignUp)
+}
+
+// SignUpPost response for sign up information submission
+func SignUpPost(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.RegisterForm)
+ ctx.Data["Title"] = ctx.Tr("sign_up")
+
+ ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/sign_up"
+
+ ctx.Data["EnableCaptcha"] = setting.Service.EnableCaptcha
+ ctx.Data["RecaptchaURL"] = setting.Service.RecaptchaURL
+ ctx.Data["Captcha"] = context.GetImageCaptcha()
+ ctx.Data["CaptchaType"] = setting.Service.CaptchaType
+ ctx.Data["RecaptchaSitekey"] = setting.Service.RecaptchaSitekey
+ ctx.Data["HcaptchaSitekey"] = setting.Service.HcaptchaSitekey
+ ctx.Data["PageIsSignUp"] = true
+
+ //Permission denied if DisableRegistration or AllowOnlyExternalRegistration options are true
+ if setting.Service.DisableRegistration || setting.Service.AllowOnlyExternalRegistration {
+ ctx.Error(http.StatusForbidden)
+ return
+ }
+
+ if ctx.HasError() {
+ ctx.HTML(http.StatusOK, tplSignUp)
+ return
+ }
+
+ if setting.Service.EnableCaptcha {
+ var valid bool
+ var err error
+ switch setting.Service.CaptchaType {
+ case setting.ImageCaptcha:
+ valid = context.GetImageCaptcha().VerifyReq(ctx.Req)
+ case setting.ReCaptcha:
+ valid, err = recaptcha.Verify(ctx, form.GRecaptchaResponse)
+ case setting.HCaptcha:
+ valid, err = hcaptcha.Verify(ctx, form.HcaptchaResponse)
+ default:
+ ctx.ServerError("Unknown Captcha Type", fmt.Errorf("Unknown Captcha Type: %s", setting.Service.CaptchaType))
+ return
+ }
+ if err != nil {
+ log.Debug("%s", err.Error())
+ }
+
+ if !valid {
+ ctx.Data["Err_Captcha"] = true
+ ctx.RenderWithErr(ctx.Tr("form.captcha_incorrect"), tplSignUp, &form)
+ return
+ }
+ }
+
+ if !form.IsEmailDomainAllowed() {
+ ctx.RenderWithErr(ctx.Tr("auth.email_domain_blacklisted"), tplSignUp, &form)
+ return
+ }
+
+ if form.Password != form.Retype {
+ ctx.Data["Err_Password"] = true
+ ctx.RenderWithErr(ctx.Tr("form.password_not_match"), tplSignUp, &form)
+ return
+ }
+ if len(form.Password) < setting.MinPasswordLength {
+ ctx.Data["Err_Password"] = true
+ ctx.RenderWithErr(ctx.Tr("auth.password_too_short", setting.MinPasswordLength), tplSignUp, &form)
+ return
+ }
+ if !password.IsComplexEnough(form.Password) {
+ ctx.Data["Err_Password"] = true
+ ctx.RenderWithErr(password.BuildComplexityError(ctx), tplSignUp, &form)
+ return
+ }
+ pwned, err := password.IsPwned(ctx, form.Password)
+ if pwned {
+ errMsg := ctx.Tr("auth.password_pwned")
+ if err != nil {
+ log.Error(err.Error())
+ errMsg = ctx.Tr("auth.password_pwned_err")
+ }
+ ctx.Data["Err_Password"] = true
+ ctx.RenderWithErr(errMsg, tplSignUp, &form)
+ return
+ }
+
+ u := &models.User{
+ Name: form.UserName,
+ Email: form.Email,
+ Passwd: form.Password,
+ IsActive: !(setting.Service.RegisterEmailConfirm || setting.Service.RegisterManualConfirm),
+ }
+
+ if !createAndHandleCreatedUser(ctx, tplSignUp, form, u, nil, false) {
+ // error already handled
+ return
+ }
+
+ ctx.Flash.Success(ctx.Tr("auth.sign_up_successful"))
+ handleSignInFull(ctx, u, false, true)
+}
+
+// createAndHandleCreatedUser calls createUserInContext and
+// then handleUserCreated.
+func createAndHandleCreatedUser(ctx *context.Context, tpl base.TplName, form interface{}, u *models.User, gothUser *goth.User, allowLink bool) bool {
+ if !createUserInContext(ctx, tpl, form, u, gothUser, allowLink) {
+ return false
+ }
+ return handleUserCreated(ctx, u, gothUser)
+}
+
+// createUserInContext creates a user and handles errors within a given context.
+// Optionally a template can be specified.
+func createUserInContext(ctx *context.Context, tpl base.TplName, form interface{}, u *models.User, gothUser *goth.User, allowLink bool) (ok bool) {
+ if err := models.CreateUser(u); err != nil {
+ if allowLink && (models.IsErrUserAlreadyExist(err) || models.IsErrEmailAlreadyUsed(err)) {
+ if setting.OAuth2Client.AccountLinking == setting.OAuth2AccountLinkingAuto {
+ var user *models.User
+ user = &models.User{Name: u.Name}
+ hasUser, err := models.GetUser(user)
+ if !hasUser || err != nil {
+ user = &models.User{Email: u.Email}
+ hasUser, err = models.GetUser(user)
+ if !hasUser || err != nil {
+ ctx.ServerError("UserLinkAccount", err)
+ return
+ }
+ }
+
+ // TODO: probably we should respect 'remember' user's choice...
+ linkAccount(ctx, user, *gothUser, true)
+ return // user is already created here, all redirects are handled
+ } else if setting.OAuth2Client.AccountLinking == setting.OAuth2AccountLinkingLogin {
+ showLinkingLogin(ctx, *gothUser)
+ return // user will be created only after linking login
+ }
+ }
+
+ // handle error without template
+ if len(tpl) == 0 {
+ ctx.ServerError("CreateUser", err)
+ return
+ }
+
+ // handle error with template
+ switch {
+ case models.IsErrUserAlreadyExist(err):
+ ctx.Data["Err_UserName"] = true
+ ctx.RenderWithErr(ctx.Tr("form.username_been_taken"), tpl, form)
+ case models.IsErrEmailAlreadyUsed(err):
+ ctx.Data["Err_Email"] = true
+ ctx.RenderWithErr(ctx.Tr("form.email_been_used"), tpl, form)
+ case models.IsErrEmailInvalid(err):
+ ctx.Data["Err_Email"] = true
+ ctx.RenderWithErr(ctx.Tr("form.email_invalid"), tpl, form)
+ case models.IsErrNameReserved(err):
+ ctx.Data["Err_UserName"] = true
+ ctx.RenderWithErr(ctx.Tr("user.form.name_reserved", err.(models.ErrNameReserved).Name), tpl, form)
+ case models.IsErrNamePatternNotAllowed(err):
+ ctx.Data["Err_UserName"] = true
+ ctx.RenderWithErr(ctx.Tr("user.form.name_pattern_not_allowed", err.(models.ErrNamePatternNotAllowed).Pattern), tpl, form)
+ case models.IsErrNameCharsNotAllowed(err):
+ ctx.Data["Err_UserName"] = true
+ ctx.RenderWithErr(ctx.Tr("user.form.name_chars_not_allowed", err.(models.ErrNameCharsNotAllowed).Name), tpl, form)
+ default:
+ ctx.ServerError("CreateUser", err)
+ }
+ return
+ }
+ log.Trace("Account created: %s", u.Name)
+ return true
+}
+
+// handleUserCreated does additional steps after a new user is created.
+// It auto-sets admin for the only user, updates the optional external user and
+// sends a confirmation email if required.
+func handleUserCreated(ctx *context.Context, u *models.User, gothUser *goth.User) (ok bool) {
+ // Auto-set admin for the only user.
+ if models.CountUsers() == 1 {
+ u.IsAdmin = true
+ u.IsActive = true
+ u.SetLastLogin()
+ if err := models.UpdateUserCols(u, "is_admin", "is_active", "last_login_unix"); err != nil {
+ ctx.ServerError("UpdateUser", err)
+ return
+ }
+ }
+
+ // update external user information
+ if gothUser != nil {
+ if err := models.UpdateExternalUser(u, *gothUser); err != nil {
+ log.Error("UpdateExternalUser failed: %v", err)
+ }
+ }
+
+ // Send confirmation email
+ if !u.IsActive && u.ID > 1 {
+ mailer.SendActivateAccountMail(ctx.Locale, u)
+
+ ctx.Data["IsSendRegisterMail"] = true
+ ctx.Data["Email"] = u.Email
+ ctx.Data["ActiveCodeLives"] = timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, ctx.Locale.Language())
+ ctx.HTML(http.StatusOK, TplActivate)
+
+ if err := ctx.Cache.Put("MailResendLimit_"+u.LowerName, u.LowerName, 180); err != nil {
+ log.Error("Set cache(MailResendLimit) fail: %v", err)
+ }
+ return
+ }
+
+ return true
+}
+
+// Activate render activate user page
+func Activate(ctx *context.Context) {
+ code := ctx.Query("code")
+
+ if len(code) == 0 {
+ ctx.Data["IsActivatePage"] = true
+ if ctx.User == nil || ctx.User.IsActive {
+ ctx.NotFound("invalid user", nil)
+ return
+ }
+ // Resend confirmation email.
+ if setting.Service.RegisterEmailConfirm {
+ if ctx.Cache.IsExist("MailResendLimit_" + ctx.User.LowerName) {
+ ctx.Data["ResendLimited"] = true
+ } else {
+ ctx.Data["ActiveCodeLives"] = timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, ctx.Locale.Language())
+ mailer.SendActivateAccountMail(ctx.Locale, ctx.User)
+
+ if err := ctx.Cache.Put("MailResendLimit_"+ctx.User.LowerName, ctx.User.LowerName, 180); err != nil {
+ log.Error("Set cache(MailResendLimit) fail: %v", err)
+ }
+ }
+ } else {
+ ctx.Data["ServiceNotEnabled"] = true
+ }
+ ctx.HTML(http.StatusOK, TplActivate)
+ return
+ }
+
+ user := models.VerifyUserActiveCode(code)
+ // if code is wrong
+ if user == nil {
+ ctx.Data["IsActivateFailed"] = true
+ ctx.HTML(http.StatusOK, TplActivate)
+ return
+ }
+
+ // if account is local account, verify password
+ if user.LoginSource == 0 {
+ ctx.Data["Code"] = code
+ ctx.Data["NeedsPassword"] = true
+ ctx.HTML(http.StatusOK, TplActivate)
+ return
+ }
+
+ handleAccountActivation(ctx, user)
+}
+
+// ActivatePost handles account activation with password check
+func ActivatePost(ctx *context.Context) {
+ code := ctx.Query("code")
+ if len(code) == 0 {
+ ctx.Redirect(setting.AppSubURL + "/user/activate")
+ return
+ }
+
+ user := models.VerifyUserActiveCode(code)
+ // if code is wrong
+ if user == nil {
+ ctx.Data["IsActivateFailed"] = true
+ ctx.HTML(http.StatusOK, TplActivate)
+ return
+ }
+
+ // if account is local account, verify password
+ if user.LoginSource == 0 {
+ password := ctx.Query("password")
+ if len(password) == 0 {
+ ctx.Data["Code"] = code
+ ctx.Data["NeedsPassword"] = true
+ ctx.HTML(http.StatusOK, TplActivate)
+ return
+ }
+ if !user.ValidatePassword(password) {
+ ctx.Data["IsActivateFailed"] = true
+ ctx.HTML(http.StatusOK, TplActivate)
+ return
+ }
+ }
+
+ handleAccountActivation(ctx, user)
+}
+
+func handleAccountActivation(ctx *context.Context, user *models.User) {
+ user.IsActive = true
+ var err error
+ if user.Rands, err = models.GetUserSalt(); err != nil {
+ ctx.ServerError("UpdateUser", err)
+ return
+ }
+ if err := models.UpdateUserCols(user, "is_active", "rands"); err != nil {
+ if models.IsErrUserNotExist(err) {
+ ctx.NotFound("UpdateUserCols", err)
+ } else {
+ ctx.ServerError("UpdateUser", err)
+ }
+ return
+ }
+
+ log.Trace("User activated: %s", user.Name)
+
+ if err := ctx.Session.Set("uid", user.ID); err != nil {
+ log.Error(fmt.Sprintf("Error setting uid in session: %v", err))
+ }
+ if err := ctx.Session.Set("uname", user.Name); err != nil {
+ log.Error(fmt.Sprintf("Error setting uname in session: %v", err))
+ }
+ if err := ctx.Session.Release(); err != nil {
+ log.Error("Error storing session: %v", err)
+ }
+
+ ctx.Flash.Success(ctx.Tr("auth.account_activated"))
+ ctx.Redirect(setting.AppSubURL + "/")
+}
+
+// ActivateEmail render the activate email page
+func ActivateEmail(ctx *context.Context) {
+ code := ctx.Query("code")
+ emailStr := ctx.Query("email")
+
+ // Verify code.
+ if email := models.VerifyActiveEmailCode(code, emailStr); email != nil {
+ if err := email.Activate(); err != nil {
+ ctx.ServerError("ActivateEmail", err)
+ }
+
+ log.Trace("Email activated: %s", email.Email)
+ ctx.Flash.Success(ctx.Tr("settings.add_email_success"))
+
+ if u, err := models.GetUserByID(email.UID); err != nil {
+ log.Warn("GetUserByID: %d", email.UID)
+ } else {
+ // Allow user to validate more emails
+ _ = ctx.Cache.Delete("MailResendLimit_" + u.LowerName)
+ }
+ }
+
+ // FIXME: e-mail verification does not require the user to be logged in,
+ // so this could be redirecting to the login page.
+ // Should users be logged in automatically here? (consider 2FA requirements, etc.)
+ ctx.Redirect(setting.AppSubURL + "/user/settings/account")
+}
+
+// ForgotPasswd render the forget pasword page
+func ForgotPasswd(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("auth.forgot_password_title")
+
+ if setting.MailService == nil {
+ ctx.Data["IsResetDisable"] = true
+ ctx.HTML(http.StatusOK, tplForgotPassword)
+ return
+ }
+
+ email := ctx.Query("email")
+ ctx.Data["Email"] = email
+
+ ctx.Data["IsResetRequest"] = true
+ ctx.HTML(http.StatusOK, tplForgotPassword)
+}
+
+// ForgotPasswdPost response for forget password request
+func ForgotPasswdPost(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("auth.forgot_password_title")
+
+ if setting.MailService == nil {
+ ctx.NotFound("ForgotPasswdPost", nil)
+ return
+ }
+ ctx.Data["IsResetRequest"] = true
+
+ email := ctx.Query("email")
+ ctx.Data["Email"] = email
+
+ u, err := models.GetUserByEmail(email)
+ if err != nil {
+ if models.IsErrUserNotExist(err) {
+ ctx.Data["ResetPwdCodeLives"] = timeutil.MinutesToFriendly(setting.Service.ResetPwdCodeLives, ctx.Locale.Language())
+ ctx.Data["IsResetSent"] = true
+ ctx.HTML(http.StatusOK, tplForgotPassword)
+ return
+ }
+
+ ctx.ServerError("user.ResetPasswd(check existence)", err)
+ return
+ }
+
+ if !u.IsLocal() && !u.IsOAuth2() {
+ ctx.Data["Err_Email"] = true
+ ctx.RenderWithErr(ctx.Tr("auth.non_local_account"), tplForgotPassword, nil)
+ return
+ }
+
+ if ctx.Cache.IsExist("MailResendLimit_" + u.LowerName) {
+ ctx.Data["ResendLimited"] = true
+ ctx.HTML(http.StatusOK, tplForgotPassword)
+ return
+ }
+
+ mailer.SendResetPasswordMail(u)
+
+ if err = ctx.Cache.Put("MailResendLimit_"+u.LowerName, u.LowerName, 180); err != nil {
+ log.Error("Set cache(MailResendLimit) fail: %v", err)
+ }
+
+ ctx.Data["ResetPwdCodeLives"] = timeutil.MinutesToFriendly(setting.Service.ResetPwdCodeLives, ctx.Locale.Language())
+ ctx.Data["IsResetSent"] = true
+ ctx.HTML(http.StatusOK, tplForgotPassword)
+}
+
+func commonResetPassword(ctx *context.Context) (*models.User, *models.TwoFactor) {
+ code := ctx.Query("code")
+
+ ctx.Data["Title"] = ctx.Tr("auth.reset_password")
+ ctx.Data["Code"] = code
+
+ if nil != ctx.User {
+ ctx.Data["user_signed_in"] = true
+ }
+
+ if len(code) == 0 {
+ ctx.Flash.Error(ctx.Tr("auth.invalid_code"))
+ return nil, nil
+ }
+
+ // Fail early, don't frustrate the user
+ u := models.VerifyUserActiveCode(code)
+ if u == nil {
+ ctx.Flash.Error(ctx.Tr("auth.invalid_code"))
+ return nil, nil
+ }
+
+ twofa, err := models.GetTwoFactorByUID(u.ID)
+ if err != nil {
+ if !models.IsErrTwoFactorNotEnrolled(err) {
+ ctx.Error(http.StatusInternalServerError, "CommonResetPassword", err.Error())
+ return nil, nil
+ }
+ } else {
+ ctx.Data["has_two_factor"] = true
+ ctx.Data["scratch_code"] = ctx.QueryBool("scratch_code")
+ }
+
+ // Show the user that they are affecting the account that they intended to
+ ctx.Data["user_email"] = u.Email
+
+ if nil != ctx.User && u.ID != ctx.User.ID {
+ ctx.Flash.Error(ctx.Tr("auth.reset_password_wrong_user", ctx.User.Email, u.Email))
+ return nil, nil
+ }
+
+ return u, twofa
+}
+
+// ResetPasswd render the account recovery page
+func ResetPasswd(ctx *context.Context) {
+ ctx.Data["IsResetForm"] = true
+
+ commonResetPassword(ctx)
+ if ctx.Written() {
+ return
+ }
+
+ ctx.HTML(http.StatusOK, tplResetPassword)
+}
+
+// ResetPasswdPost response from account recovery request
+func ResetPasswdPost(ctx *context.Context) {
+ u, twofa := commonResetPassword(ctx)
+ if ctx.Written() {
+ return
+ }
+
+ if u == nil {
+ // Flash error has been set
+ ctx.HTML(http.StatusOK, tplResetPassword)
+ return
+ }
+
+ // Validate password length.
+ passwd := ctx.Query("password")
+ if len(passwd) < setting.MinPasswordLength {
+ ctx.Data["IsResetForm"] = true
+ ctx.Data["Err_Password"] = true
+ ctx.RenderWithErr(ctx.Tr("auth.password_too_short", setting.MinPasswordLength), tplResetPassword, nil)
+ return
+ } else if !password.IsComplexEnough(passwd) {
+ ctx.Data["IsResetForm"] = true
+ ctx.Data["Err_Password"] = true
+ ctx.RenderWithErr(password.BuildComplexityError(ctx), tplResetPassword, nil)
+ return
+ } else if pwned, err := password.IsPwned(ctx, passwd); pwned || err != nil {
+ errMsg := ctx.Tr("auth.password_pwned")
+ if err != nil {
+ log.Error(err.Error())
+ errMsg = ctx.Tr("auth.password_pwned_err")
+ }
+ ctx.Data["IsResetForm"] = true
+ ctx.Data["Err_Password"] = true
+ ctx.RenderWithErr(errMsg, tplResetPassword, nil)
+ return
+ }
+
+ // Handle two-factor
+ regenerateScratchToken := false
+ if twofa != nil {
+ if ctx.QueryBool("scratch_code") {
+ if !twofa.VerifyScratchToken(ctx.Query("token")) {
+ ctx.Data["IsResetForm"] = true
+ ctx.Data["Err_Token"] = true
+ ctx.RenderWithErr(ctx.Tr("auth.twofa_scratch_token_incorrect"), tplResetPassword, nil)
+ return
+ }
+ regenerateScratchToken = true
+ } else {
+ passcode := ctx.Query("passcode")
+ ok, err := twofa.ValidateTOTP(passcode)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "ValidateTOTP", err.Error())
+ return
+ }
+ if !ok || twofa.LastUsedPasscode == passcode {
+ ctx.Data["IsResetForm"] = true
+ ctx.Data["Err_Passcode"] = true
+ ctx.RenderWithErr(ctx.Tr("auth.twofa_passcode_incorrect"), tplResetPassword, nil)
+ return
+ }
+
+ twofa.LastUsedPasscode = passcode
+ if err = models.UpdateTwoFactor(twofa); err != nil {
+ ctx.ServerError("ResetPasswdPost: UpdateTwoFactor", err)
+ return
+ }
+ }
+ }
+ var err error
+ if u.Rands, err = models.GetUserSalt(); err != nil {
+ ctx.ServerError("UpdateUser", err)
+ return
+ }
+ if err = u.SetPassword(passwd); err != nil {
+ ctx.ServerError("UpdateUser", err)
+ return
+ }
+ u.MustChangePassword = false
+ if err := models.UpdateUserCols(u, "must_change_password", "passwd", "passwd_hash_algo", "rands", "salt"); err != nil {
+ ctx.ServerError("UpdateUser", err)
+ return
+ }
+
+ log.Trace("User password reset: %s", u.Name)
+ ctx.Data["IsResetFailed"] = true
+ remember := len(ctx.Query("remember")) != 0
+
+ if regenerateScratchToken {
+ // Invalidate the scratch token.
+ _, err = twofa.GenerateScratchToken()
+ if err != nil {
+ ctx.ServerError("UserSignIn", err)
+ return
+ }
+ if err = models.UpdateTwoFactor(twofa); err != nil {
+ ctx.ServerError("UserSignIn", err)
+ return
+ }
+
+ handleSignInFull(ctx, u, remember, false)
+ ctx.Flash.Info(ctx.Tr("auth.twofa_scratch_used"))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/security")
+ return
+ }
+
+ handleSignInFull(ctx, u, remember, true)
+}
+
+// MustChangePassword renders the page to change a user's password
+func MustChangePassword(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("auth.must_change_password")
+ ctx.Data["ChangePasscodeLink"] = setting.AppSubURL + "/user/settings/change_password"
+ ctx.Data["MustChangePassword"] = true
+ ctx.HTML(http.StatusOK, tplMustChangePassword)
+}
+
+// MustChangePasswordPost response for updating a user's password after his/her
+// account was created by an admin
+func MustChangePasswordPost(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.MustChangePasswordForm)
+ ctx.Data["Title"] = ctx.Tr("auth.must_change_password")
+ ctx.Data["ChangePasscodeLink"] = setting.AppSubURL + "/user/settings/change_password"
+ if ctx.HasError() {
+ ctx.HTML(http.StatusOK, tplMustChangePassword)
+ return
+ }
+ u := ctx.User
+ // Make sure only requests for users who are eligible to change their password via
+ // this method passes through
+ if !u.MustChangePassword {
+ ctx.ServerError("MustUpdatePassword", errors.New("cannot update password.. Please visit the settings page"))
+ return
+ }
+
+ if form.Password != form.Retype {
+ ctx.Data["Err_Password"] = true
+ ctx.RenderWithErr(ctx.Tr("form.password_not_match"), tplMustChangePassword, &form)
+ return
+ }
+
+ if len(form.Password) < setting.MinPasswordLength {
+ ctx.Data["Err_Password"] = true
+ ctx.RenderWithErr(ctx.Tr("auth.password_too_short", setting.MinPasswordLength), tplMustChangePassword, &form)
+ return
+ }
+
+ var err error
+ if err = u.SetPassword(form.Password); err != nil {
+ ctx.ServerError("UpdateUser", err)
+ return
+ }
+
+ u.MustChangePassword = false
+
+ if err := models.UpdateUserCols(u, "must_change_password", "passwd", "passwd_hash_algo", "salt"); err != nil {
+ ctx.ServerError("UpdateUser", err)
+ return
+ }
+
+ ctx.Flash.Success(ctx.Tr("settings.change_password_success"))
+
+ log.Trace("User updated password: %s", u.Name)
+
+ if redirectTo := ctx.GetCookie("redirect_to"); len(redirectTo) > 0 && !utils.IsExternalURL(redirectTo) {
+ middleware.DeleteRedirectToCookie(ctx.Resp)
+ ctx.RedirectToFirst(redirectTo)
+ return
+ }
+
+ ctx.Redirect(setting.AppSubURL + "/")
+}
diff --git a/routers/web/user/auth_openid.go b/routers/web/user/auth_openid.go
new file mode 100644
index 0000000000..1a73a08c48
--- /dev/null
+++ b/routers/web/user/auth_openid.go
@@ -0,0 +1,450 @@
+// Copyright 2017 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
+
+import (
+ "fmt"
+ "net/http"
+ "net/url"
+
+ "code.gitea.io/gitea/models"
+ "code.gitea.io/gitea/modules/auth/openid"
+ "code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/context"
+ "code.gitea.io/gitea/modules/hcaptcha"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/recaptcha"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/modules/web/middleware"
+ "code.gitea.io/gitea/services/forms"
+)
+
+const (
+ tplSignInOpenID base.TplName = "user/auth/signin_openid"
+ tplConnectOID base.TplName = "user/auth/signup_openid_connect"
+ tplSignUpOID base.TplName = "user/auth/signup_openid_register"
+)
+
+// SignInOpenID render sign in page
+func SignInOpenID(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("sign_in")
+
+ if ctx.Query("openid.return_to") != "" {
+ signInOpenIDVerify(ctx)
+ return
+ }
+
+ // Check auto-login.
+ isSucceed, err := AutoSignIn(ctx)
+ if err != nil {
+ ctx.ServerError("AutoSignIn", err)
+ return
+ }
+
+ redirectTo := ctx.Query("redirect_to")
+ if len(redirectTo) > 0 {
+ middleware.SetRedirectToCookie(ctx.Resp, redirectTo)
+ } else {
+ redirectTo = ctx.GetCookie("redirect_to")
+ }
+
+ if isSucceed {
+ middleware.DeleteRedirectToCookie(ctx.Resp)
+ ctx.RedirectToFirst(redirectTo)
+ return
+ }
+
+ ctx.Data["PageIsSignIn"] = true
+ ctx.Data["PageIsLoginOpenID"] = true
+ ctx.HTML(http.StatusOK, tplSignInOpenID)
+}
+
+// Check if the given OpenID URI is allowed by blacklist/whitelist
+func allowedOpenIDURI(uri string) (err error) {
+
+ // In case a Whitelist is present, URI must be in it
+ // in order to be accepted
+ if len(setting.Service.OpenIDWhitelist) != 0 {
+ for _, pat := range setting.Service.OpenIDWhitelist {
+ if pat.MatchString(uri) {
+ return nil // pass
+ }
+ }
+ // must match one of this or be refused
+ return fmt.Errorf("URI not allowed by whitelist")
+ }
+
+ // A blacklist match expliclty forbids
+ for _, pat := range setting.Service.OpenIDBlacklist {
+ if pat.MatchString(uri) {
+ return fmt.Errorf("URI forbidden by blacklist")
+ }
+ }
+
+ return nil
+}
+
+// SignInOpenIDPost response for openid sign in request
+func SignInOpenIDPost(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.SignInOpenIDForm)
+ ctx.Data["Title"] = ctx.Tr("sign_in")
+ ctx.Data["PageIsSignIn"] = true
+ ctx.Data["PageIsLoginOpenID"] = true
+
+ if ctx.HasError() {
+ ctx.HTML(http.StatusOK, tplSignInOpenID)
+ return
+ }
+
+ id, err := openid.Normalize(form.Openid)
+ if err != nil {
+ ctx.RenderWithErr(err.Error(), tplSignInOpenID, &form)
+ return
+ }
+ form.Openid = id
+
+ log.Trace("OpenID uri: " + id)
+
+ err = allowedOpenIDURI(id)
+ if err != nil {
+ ctx.RenderWithErr(err.Error(), tplSignInOpenID, &form)
+ return
+ }
+
+ redirectTo := setting.AppURL + "user/login/openid"
+ url, err := openid.RedirectURL(id, redirectTo, setting.AppURL)
+ if err != nil {
+ log.Error("Error in OpenID redirect URL: %s, %v", redirectTo, err.Error())
+ ctx.RenderWithErr(fmt.Sprintf("Unable to find OpenID provider in %s", redirectTo), tplSignInOpenID, &form)
+ return
+ }
+
+ // Request optional nickname and email info
+ // NOTE: change to `openid.sreg.required` to require it
+ url += "&openid.ns.sreg=http%3A%2F%2Fopenid.net%2Fextensions%2Fsreg%2F1.1"
+ url += "&openid.sreg.optional=nickname%2Cemail"
+
+ log.Trace("Form-passed openid-remember: %t", form.Remember)
+
+ if err := ctx.Session.Set("openid_signin_remember", form.Remember); err != nil {
+ log.Error("SignInOpenIDPost: Could not set openid_signin_remember in session: %v", err)
+ }
+ if err := ctx.Session.Release(); err != nil {
+ log.Error("SignInOpenIDPost: Unable to save changes to the session: %v", err)
+ }
+
+ ctx.Redirect(url)
+}
+
+// signInOpenIDVerify handles response from OpenID provider
+func signInOpenIDVerify(ctx *context.Context) {
+
+ log.Trace("Incoming call to: " + ctx.Req.URL.String())
+
+ fullURL := setting.AppURL + ctx.Req.URL.String()[1:]
+ log.Trace("Full URL: " + fullURL)
+
+ var id, err = openid.Verify(fullURL)
+ if err != nil {
+ ctx.RenderWithErr(err.Error(), tplSignInOpenID, &forms.SignInOpenIDForm{
+ Openid: id,
+ })
+ return
+ }
+
+ log.Trace("Verified ID: " + id)
+
+ /* Now we should seek for the user and log him in, or prompt
+ * to register if not found */
+
+ u, err := models.GetUserByOpenID(id)
+ if err != nil {
+ if !models.IsErrUserNotExist(err) {
+ ctx.RenderWithErr(err.Error(), tplSignInOpenID, &forms.SignInOpenIDForm{
+ Openid: id,
+ })
+ return
+ }
+ log.Error("signInOpenIDVerify: %v", err)
+ }
+ if u != nil {
+ log.Trace("User exists, logging in")
+ remember, _ := ctx.Session.Get("openid_signin_remember").(bool)
+ log.Trace("Session stored openid-remember: %t", remember)
+ handleSignIn(ctx, u, remember)
+ return
+ }
+
+ log.Trace("User with openid " + id + " does not exist, should connect or register")
+
+ parsedURL, err := url.Parse(fullURL)
+ if err != nil {
+ ctx.RenderWithErr(err.Error(), tplSignInOpenID, &forms.SignInOpenIDForm{
+ Openid: id,
+ })
+ return
+ }
+ values, err := url.ParseQuery(parsedURL.RawQuery)
+ if err != nil {
+ ctx.RenderWithErr(err.Error(), tplSignInOpenID, &forms.SignInOpenIDForm{
+ Openid: id,
+ })
+ return
+ }
+ email := values.Get("openid.sreg.email")
+ nickname := values.Get("openid.sreg.nickname")
+
+ log.Trace("User has email=" + email + " and nickname=" + nickname)
+
+ if email != "" {
+ u, err = models.GetUserByEmail(email)
+ if err != nil {
+ if !models.IsErrUserNotExist(err) {
+ ctx.RenderWithErr(err.Error(), tplSignInOpenID, &forms.SignInOpenIDForm{
+ Openid: id,
+ })
+ return
+ }
+ log.Error("signInOpenIDVerify: %v", err)
+ }
+ if u != nil {
+ log.Trace("Local user " + u.LowerName + " has OpenID provided email " + email)
+ }
+ }
+
+ if u == nil && nickname != "" {
+ u, _ = models.GetUserByName(nickname)
+ if err != nil {
+ if !models.IsErrUserNotExist(err) {
+ ctx.RenderWithErr(err.Error(), tplSignInOpenID, &forms.SignInOpenIDForm{
+ Openid: id,
+ })
+ return
+ }
+ }
+ if u != nil {
+ log.Trace("Local user " + u.LowerName + " has OpenID provided nickname " + nickname)
+ }
+ }
+
+ if err := ctx.Session.Set("openid_verified_uri", id); err != nil {
+ log.Error("signInOpenIDVerify: Could not set openid_verified_uri in session: %v", err)
+ }
+ if err := ctx.Session.Set("openid_determined_email", email); err != nil {
+ log.Error("signInOpenIDVerify: Could not set openid_determined_email in session: %v", err)
+ }
+
+ if u != nil {
+ nickname = u.LowerName
+ }
+
+ if err := ctx.Session.Set("openid_determined_username", nickname); err != nil {
+ log.Error("signInOpenIDVerify: Could not set openid_determined_username in session: %v", err)
+ }
+ if err := ctx.Session.Release(); err != nil {
+ log.Error("signInOpenIDVerify: Unable to save changes to the session: %v", err)
+ }
+
+ if u != nil || !setting.Service.EnableOpenIDSignUp || setting.Service.AllowOnlyInternalRegistration {
+ ctx.Redirect(setting.AppSubURL + "/user/openid/connect")
+ } else {
+ ctx.Redirect(setting.AppSubURL + "/user/openid/register")
+ }
+}
+
+// ConnectOpenID shows a form to connect an OpenID URI to an existing account
+func ConnectOpenID(ctx *context.Context) {
+ oid, _ := ctx.Session.Get("openid_verified_uri").(string)
+ if oid == "" {
+ ctx.Redirect(setting.AppSubURL + "/user/login/openid")
+ return
+ }
+ ctx.Data["Title"] = "OpenID connect"
+ ctx.Data["PageIsSignIn"] = true
+ ctx.Data["PageIsOpenIDConnect"] = true
+ ctx.Data["EnableOpenIDSignUp"] = setting.Service.EnableOpenIDSignUp
+ ctx.Data["AllowOnlyInternalRegistration"] = setting.Service.AllowOnlyInternalRegistration
+ ctx.Data["OpenID"] = oid
+ userName, _ := ctx.Session.Get("openid_determined_username").(string)
+ if userName != "" {
+ ctx.Data["user_name"] = userName
+ }
+ ctx.HTML(http.StatusOK, tplConnectOID)
+}
+
+// ConnectOpenIDPost handles submission of a form to connect an OpenID URI to an existing account
+func ConnectOpenIDPost(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.ConnectOpenIDForm)
+ oid, _ := ctx.Session.Get("openid_verified_uri").(string)
+ if oid == "" {
+ ctx.Redirect(setting.AppSubURL + "/user/login/openid")
+ return
+ }
+ ctx.Data["Title"] = "OpenID connect"
+ ctx.Data["PageIsSignIn"] = true
+ ctx.Data["PageIsOpenIDConnect"] = true
+ ctx.Data["EnableOpenIDSignUp"] = setting.Service.EnableOpenIDSignUp
+ ctx.Data["OpenID"] = oid
+
+ u, err := models.UserSignIn(form.UserName, form.Password)
+ if err != nil {
+ if models.IsErrUserNotExist(err) {
+ ctx.RenderWithErr(ctx.Tr("form.username_password_incorrect"), tplConnectOID, &form)
+ } else {
+ ctx.ServerError("ConnectOpenIDPost", err)
+ }
+ return
+ }
+
+ // add OpenID for the user
+ userOID := &models.UserOpenID{UID: u.ID, URI: oid}
+ if err = models.AddUserOpenID(userOID); err != nil {
+ if models.IsErrOpenIDAlreadyUsed(err) {
+ ctx.RenderWithErr(ctx.Tr("form.openid_been_used", oid), tplConnectOID, &form)
+ return
+ }
+ ctx.ServerError("AddUserOpenID", err)
+ return
+ }
+
+ ctx.Flash.Success(ctx.Tr("settings.add_openid_success"))
+
+ remember, _ := ctx.Session.Get("openid_signin_remember").(bool)
+ log.Trace("Session stored openid-remember: %t", remember)
+ handleSignIn(ctx, u, remember)
+}
+
+// RegisterOpenID shows a form to create a new user authenticated via an OpenID URI
+func RegisterOpenID(ctx *context.Context) {
+ oid, _ := ctx.Session.Get("openid_verified_uri").(string)
+ if oid == "" {
+ ctx.Redirect(setting.AppSubURL + "/user/login/openid")
+ return
+ }
+ ctx.Data["Title"] = "OpenID signup"
+ ctx.Data["PageIsSignIn"] = true
+ ctx.Data["PageIsOpenIDRegister"] = true
+ ctx.Data["EnableOpenIDSignUp"] = setting.Service.EnableOpenIDSignUp
+ ctx.Data["AllowOnlyInternalRegistration"] = setting.Service.AllowOnlyInternalRegistration
+ ctx.Data["EnableCaptcha"] = setting.Service.EnableCaptcha
+ ctx.Data["Captcha"] = context.GetImageCaptcha()
+ ctx.Data["CaptchaType"] = setting.Service.CaptchaType
+ ctx.Data["RecaptchaSitekey"] = setting.Service.RecaptchaSitekey
+ ctx.Data["HcaptchaSitekey"] = setting.Service.HcaptchaSitekey
+ ctx.Data["RecaptchaURL"] = setting.Service.RecaptchaURL
+ ctx.Data["OpenID"] = oid
+ userName, _ := ctx.Session.Get("openid_determined_username").(string)
+ if userName != "" {
+ ctx.Data["user_name"] = userName
+ }
+ email, _ := ctx.Session.Get("openid_determined_email").(string)
+ if email != "" {
+ ctx.Data["email"] = email
+ }
+ ctx.HTML(http.StatusOK, tplSignUpOID)
+}
+
+// RegisterOpenIDPost handles submission of a form to create a new user authenticated via an OpenID URI
+func RegisterOpenIDPost(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.SignUpOpenIDForm)
+ oid, _ := ctx.Session.Get("openid_verified_uri").(string)
+ if oid == "" {
+ ctx.Redirect(setting.AppSubURL + "/user/login/openid")
+ return
+ }
+
+ ctx.Data["Title"] = "OpenID signup"
+ ctx.Data["PageIsSignIn"] = true
+ ctx.Data["PageIsOpenIDRegister"] = true
+ ctx.Data["EnableOpenIDSignUp"] = setting.Service.EnableOpenIDSignUp
+ ctx.Data["EnableCaptcha"] = setting.Service.EnableCaptcha
+ ctx.Data["RecaptchaURL"] = setting.Service.RecaptchaURL
+ ctx.Data["Captcha"] = context.GetImageCaptcha()
+ ctx.Data["CaptchaType"] = setting.Service.CaptchaType
+ ctx.Data["RecaptchaSitekey"] = setting.Service.RecaptchaSitekey
+ ctx.Data["HcaptchaSitekey"] = setting.Service.HcaptchaSitekey
+ ctx.Data["OpenID"] = oid
+
+ if setting.Service.AllowOnlyInternalRegistration {
+ ctx.Error(http.StatusForbidden)
+ return
+ }
+
+ if setting.Service.EnableCaptcha {
+ var valid bool
+ var err error
+ switch setting.Service.CaptchaType {
+ case setting.ImageCaptcha:
+ valid = context.GetImageCaptcha().VerifyReq(ctx.Req)
+ case setting.ReCaptcha:
+ if err := ctx.Req.ParseForm(); err != nil {
+ ctx.ServerError("", err)
+ return
+ }
+ valid, err = recaptcha.Verify(ctx, form.GRecaptchaResponse)
+ case setting.HCaptcha:
+ if err := ctx.Req.ParseForm(); err != nil {
+ ctx.ServerError("", err)
+ return
+ }
+ valid, err = hcaptcha.Verify(ctx, form.HcaptchaResponse)
+ default:
+ ctx.ServerError("Unknown Captcha Type", fmt.Errorf("Unknown Captcha Type: %s", setting.Service.CaptchaType))
+ return
+ }
+ if err != nil {
+ log.Debug("%s", err.Error())
+ }
+
+ if !valid {
+ ctx.Data["Err_Captcha"] = true
+ ctx.RenderWithErr(ctx.Tr("form.captcha_incorrect"), tplSignUpOID, &form)
+ return
+ }
+ }
+
+ length := setting.MinPasswordLength
+ if length < 256 {
+ length = 256
+ }
+ password, err := util.RandomString(int64(length))
+ if err != nil {
+ ctx.RenderWithErr(err.Error(), tplSignUpOID, form)
+ return
+ }
+
+ u := &models.User{
+ Name: form.UserName,
+ Email: form.Email,
+ Passwd: password,
+ IsActive: !(setting.Service.RegisterEmailConfirm || setting.Service.RegisterManualConfirm),
+ }
+ if !createUserInContext(ctx, tplSignUpOID, form, u, nil, false) {
+ // error already handled
+ return
+ }
+
+ // add OpenID for the user
+ userOID := &models.UserOpenID{UID: u.ID, URI: oid}
+ if err = models.AddUserOpenID(userOID); err != nil {
+ if models.IsErrOpenIDAlreadyUsed(err) {
+ ctx.RenderWithErr(ctx.Tr("form.openid_been_used", oid), tplSignUpOID, &form)
+ return
+ }
+ ctx.ServerError("AddUserOpenID", err)
+ return
+ }
+
+ if !handleUserCreated(ctx, u, nil) {
+ // error already handled
+ return
+ }
+
+ remember, _ := ctx.Session.Get("openid_signin_remember").(bool)
+ log.Trace("Session stored openid-remember: %t", remember)
+ handleSignIn(ctx, u, remember)
+}
diff --git a/routers/web/user/avatar.go b/routers/web/user/avatar.go
new file mode 100644
index 0000000000..4287589d1a
--- /dev/null
+++ b/routers/web/user/avatar.go
@@ -0,0 +1,98 @@
+// Copyright 2019 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
+
+import (
+ "errors"
+ "net/url"
+ "path"
+ "strconv"
+ "strings"
+
+ "code.gitea.io/gitea/models"
+ "code.gitea.io/gitea/modules/context"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+)
+
+// Avatar redirect browser to user avatar of requested size
+func Avatar(ctx *context.Context) {
+ userName := ctx.Params(":username")
+ size, err := strconv.Atoi(ctx.Params(":size"))
+ if err != nil {
+ ctx.ServerError("Invalid avatar size", err)
+ return
+ }
+
+ log.Debug("Asked avatar for user %v and size %v", userName, size)
+
+ var user *models.User
+ if strings.ToLower(userName) != "ghost" {
+ user, err = models.GetUserByName(userName)
+ if err != nil {
+ if models.IsErrUserNotExist(err) {
+ ctx.ServerError("Requested avatar for invalid user", err)
+ } else {
+ ctx.ServerError("Retrieving user by name", err)
+ }
+ return
+ }
+ } else {
+ user = models.NewGhostUser()
+ }
+
+ ctx.Redirect(user.RealSizedAvatarLink(size))
+}
+
+// AvatarByEmailHash redirects the browser to the appropriate Avatar link
+func AvatarByEmailHash(ctx *context.Context) {
+ var err error
+
+ hash := ctx.Params(":hash")
+ if len(hash) == 0 {
+ ctx.ServerError("invalid avatar hash", errors.New("hash cannot be empty"))
+ return
+ }
+
+ var email string
+ email, err = models.GetEmailForHash(hash)
+ if err != nil {
+ ctx.ServerError("invalid avatar hash", err)
+ return
+ }
+ if len(email) == 0 {
+ ctx.Redirect(models.DefaultAvatarLink())
+ return
+ }
+ size := ctx.QueryInt("size")
+ if size == 0 {
+ size = models.DefaultAvatarSize
+ }
+
+ var avatarURL *url.URL
+
+ if setting.EnableFederatedAvatar && setting.LibravatarService != nil {
+ avatarURL, err = models.LibravatarURL(email)
+ if err != nil {
+ avatarURL, err = url.Parse(models.DefaultAvatarLink())
+ if err != nil {
+ ctx.ServerError("invalid default avatar url", err)
+ return
+ }
+ }
+ } else if !setting.DisableGravatar {
+ copyOfGravatarSourceURL := *setting.GravatarSourceURL
+ avatarURL = &copyOfGravatarSourceURL
+ avatarURL.Path = path.Join(avatarURL.Path, hash)
+ } else {
+ avatarURL, err = url.Parse(models.DefaultAvatarLink())
+ if err != nil {
+ ctx.ServerError("invalid default avatar url", err)
+ return
+ }
+ }
+
+ ctx.Redirect(models.MakeFinalAvatarURL(avatarURL, size))
+}
diff --git a/routers/web/user/home.go b/routers/web/user/home.go
new file mode 100644
index 0000000000..acf73f82fe
--- /dev/null
+++ b/routers/web/user/home.go
@@ -0,0 +1,913 @@
+// Copyright 2014 The Gogs Authors. All rights reserved.
+// Copyright 2019 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
+
+import (
+ "bytes"
+ "fmt"
+ "net/http"
+ "regexp"
+ "sort"
+ "strconv"
+ "strings"
+
+ "code.gitea.io/gitea/models"
+ "code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/context"
+ issue_indexer "code.gitea.io/gitea/modules/indexer/issues"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/markup"
+ "code.gitea.io/gitea/modules/markup/markdown"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/util"
+ issue_service "code.gitea.io/gitea/services/issue"
+ pull_service "code.gitea.io/gitea/services/pull"
+
+ jsoniter "github.com/json-iterator/go"
+ "github.com/keybase/go-crypto/openpgp"
+ "github.com/keybase/go-crypto/openpgp/armor"
+ "xorm.io/builder"
+)
+
+const (
+ tplDashboard base.TplName = "user/dashboard/dashboard"
+ tplIssues base.TplName = "user/dashboard/issues"
+ tplMilestones base.TplName = "user/dashboard/milestones"
+ tplProfile base.TplName = "user/profile"
+)
+
+// getDashboardContextUser finds out which context user dashboard is being viewed as .
+func getDashboardContextUser(ctx *context.Context) *models.User {
+ ctxUser := ctx.User
+ orgName := ctx.Params(":org")
+ if len(orgName) > 0 {
+ ctxUser = ctx.Org.Organization
+ ctx.Data["Teams"] = ctx.Org.Organization.Teams
+ }
+ ctx.Data["ContextUser"] = ctxUser
+
+ if err := ctx.User.GetOrganizations(&models.SearchOrganizationsOptions{All: true}); err != nil {
+ ctx.ServerError("GetOrganizations", err)
+ return nil
+ }
+ ctx.Data["Orgs"] = ctx.User.Orgs
+
+ return ctxUser
+}
+
+// retrieveFeeds loads feeds for the specified user
+func retrieveFeeds(ctx *context.Context, options models.GetFeedsOptions) {
+ actions, err := models.GetFeeds(options)
+ if err != nil {
+ ctx.ServerError("GetFeeds", err)
+ return
+ }
+
+ userCache := map[int64]*models.User{options.RequestedUser.ID: options.RequestedUser}
+ if ctx.User != nil {
+ userCache[ctx.User.ID] = ctx.User
+ }
+ for _, act := range actions {
+ if act.ActUser != nil {
+ userCache[act.ActUserID] = act.ActUser
+ }
+ }
+
+ for _, act := range actions {
+ repoOwner, ok := userCache[act.Repo.OwnerID]
+ if !ok {
+ repoOwner, err = models.GetUserByID(act.Repo.OwnerID)
+ if err != nil {
+ if models.IsErrUserNotExist(err) {
+ continue
+ }
+ ctx.ServerError("GetUserByID", err)
+ return
+ }
+ userCache[repoOwner.ID] = repoOwner
+ }
+ act.Repo.Owner = repoOwner
+ }
+ ctx.Data["Feeds"] = actions
+}
+
+// Dashboard render the dashboard page
+func Dashboard(ctx *context.Context) {
+ ctxUser := getDashboardContextUser(ctx)
+ if ctx.Written() {
+ return
+ }
+
+ ctx.Data["Title"] = ctxUser.DisplayName() + " - " + ctx.Tr("dashboard")
+ ctx.Data["PageIsDashboard"] = true
+ ctx.Data["PageIsNews"] = true
+ ctx.Data["SearchLimit"] = setting.UI.User.RepoPagingNum
+
+ if setting.Service.EnableUserHeatmap {
+ data, err := models.GetUserHeatmapDataByUserTeam(ctxUser, ctx.Org.Team, ctx.User)
+ if err != nil {
+ ctx.ServerError("GetUserHeatmapDataByUserTeam", err)
+ return
+ }
+ ctx.Data["HeatmapData"] = data
+ }
+
+ var err error
+ var mirrors []*models.Repository
+ if ctxUser.IsOrganization() {
+ var env models.AccessibleReposEnvironment
+ if ctx.Org.Team != nil {
+ env = ctxUser.AccessibleTeamReposEnv(ctx.Org.Team)
+ } else {
+ env, err = ctxUser.AccessibleReposEnv(ctx.User.ID)
+ if err != nil {
+ ctx.ServerError("AccessibleReposEnv", err)
+ return
+ }
+ }
+ mirrors, err = env.MirrorRepos()
+ if err != nil {
+ ctx.ServerError("env.MirrorRepos", err)
+ return
+ }
+ } else {
+ mirrors, err = ctxUser.GetMirrorRepositories()
+ if err != nil {
+ ctx.ServerError("GetMirrorRepositories", err)
+ return
+ }
+ }
+ ctx.Data["MaxShowRepoNum"] = setting.UI.User.RepoPagingNum
+
+ if err := models.MirrorRepositoryList(mirrors).LoadAttributes(); err != nil {
+ ctx.ServerError("MirrorRepositoryList.LoadAttributes", err)
+ return
+ }
+ ctx.Data["MirrorCount"] = len(mirrors)
+ ctx.Data["Mirrors"] = mirrors
+
+ retrieveFeeds(ctx, models.GetFeedsOptions{
+ RequestedUser: ctxUser,
+ RequestedTeam: ctx.Org.Team,
+ Actor: ctx.User,
+ IncludePrivate: true,
+ OnlyPerformedBy: false,
+ IncludeDeleted: false,
+ Date: ctx.Query("date"),
+ })
+
+ if ctx.Written() {
+ return
+ }
+ ctx.HTML(http.StatusOK, tplDashboard)
+}
+
+// Milestones render the user milestones page
+func Milestones(ctx *context.Context) {
+ if models.UnitTypeIssues.UnitGlobalDisabled() && models.UnitTypePullRequests.UnitGlobalDisabled() {
+ log.Debug("Milestones overview page not available as both issues and pull requests are globally disabled")
+ ctx.Status(404)
+ return
+ }
+
+ ctx.Data["Title"] = ctx.Tr("milestones")
+ ctx.Data["PageIsMilestonesDashboard"] = true
+
+ ctxUser := getDashboardContextUser(ctx)
+ if ctx.Written() {
+ return
+ }
+
+ repoOpts := models.SearchRepoOptions{
+ Actor: ctxUser,
+ OwnerID: ctxUser.ID,
+ Private: true,
+ AllPublic: false, // Include also all public repositories of users and public organisations
+ AllLimited: false, // Include also all public repositories of limited organisations
+ HasMilestones: util.OptionalBoolTrue, // Just needs display repos has milestones
+ }
+
+ if ctxUser.IsOrganization() && ctx.Org.Team != nil {
+ repoOpts.TeamID = ctx.Org.Team.ID
+ }
+
+ var (
+ userRepoCond = models.SearchRepositoryCondition(&repoOpts) // all repo condition user could visit
+ repoCond = userRepoCond
+ repoIDs []int64
+
+ reposQuery = ctx.Query("repos")
+ isShowClosed = ctx.Query("state") == "closed"
+ sortType = ctx.Query("sort")
+ page = ctx.QueryInt("page")
+ keyword = strings.Trim(ctx.Query("q"), " ")
+ )
+
+ if page <= 1 {
+ page = 1
+ }
+
+ if len(reposQuery) != 0 {
+ if issueReposQueryPattern.MatchString(reposQuery) {
+ // remove "[" and "]" from string
+ reposQuery = reposQuery[1 : len(reposQuery)-1]
+ //for each ID (delimiter ",") add to int to repoIDs
+
+ for _, rID := range strings.Split(reposQuery, ",") {
+ // Ensure nonempty string entries
+ if rID != "" && rID != "0" {
+ rIDint64, err := strconv.ParseInt(rID, 10, 64)
+ // If the repo id specified by query is not parseable or not accessible by user, just ignore it.
+ if err == nil {
+ repoIDs = append(repoIDs, rIDint64)
+ }
+ }
+ }
+ if len(repoIDs) > 0 {
+ // Don't just let repoCond = builder.In("id", repoIDs) because user may has no permission on repoIDs
+ // But the original repoCond has a limitation
+ repoCond = repoCond.And(builder.In("id", repoIDs))
+ }
+ } else {
+ log.Warn("issueReposQueryPattern not match with query")
+ }
+ }
+
+ counts, err := models.CountMilestonesByRepoCondAndKw(userRepoCond, keyword, isShowClosed)
+ if err != nil {
+ ctx.ServerError("CountMilestonesByRepoIDs", err)
+ return
+ }
+
+ milestones, err := models.SearchMilestones(repoCond, page, isShowClosed, sortType, keyword)
+ if err != nil {
+ ctx.ServerError("SearchMilestones", err)
+ return
+ }
+
+ showRepos, _, err := models.SearchRepositoryByCondition(&repoOpts, userRepoCond, false)
+ if err != nil {
+ ctx.ServerError("SearchRepositoryByCondition", err)
+ return
+ }
+ sort.Sort(showRepos)
+
+ for i := 0; i < len(milestones); {
+ for _, repo := range showRepos {
+ if milestones[i].RepoID == repo.ID {
+ milestones[i].Repo = repo
+ break
+ }
+ }
+ if milestones[i].Repo == nil {
+ log.Warn("Cannot find milestone %d 's repository %d", milestones[i].ID, milestones[i].RepoID)
+ milestones = append(milestones[:i], milestones[i+1:]...)
+ continue
+ }
+
+ milestones[i].RenderedContent, err = markdown.RenderString(&markup.RenderContext{
+ URLPrefix: milestones[i].Repo.Link(),
+ Metas: milestones[i].Repo.ComposeMetas(),
+ }, milestones[i].Content)
+ if err != nil {
+ ctx.ServerError("RenderString", err)
+ return
+ }
+
+ if milestones[i].Repo.IsTimetrackerEnabled() {
+ err := milestones[i].LoadTotalTrackedTime()
+ if err != nil {
+ ctx.ServerError("LoadTotalTrackedTime", err)
+ return
+ }
+ }
+ i++
+ }
+
+ milestoneStats, err := models.GetMilestonesStatsByRepoCondAndKw(repoCond, keyword)
+ if err != nil {
+ ctx.ServerError("GetMilestoneStats", err)
+ return
+ }
+
+ var totalMilestoneStats *models.MilestonesStats
+ if len(repoIDs) == 0 {
+ totalMilestoneStats = milestoneStats
+ } else {
+ totalMilestoneStats, err = models.GetMilestonesStatsByRepoCondAndKw(userRepoCond, keyword)
+ if err != nil {
+ ctx.ServerError("GetMilestoneStats", err)
+ return
+ }
+ }
+
+ var pagerCount int
+ if isShowClosed {
+ ctx.Data["State"] = "closed"
+ ctx.Data["Total"] = totalMilestoneStats.ClosedCount
+ pagerCount = int(milestoneStats.ClosedCount)
+ } else {
+ ctx.Data["State"] = "open"
+ ctx.Data["Total"] = totalMilestoneStats.OpenCount
+ pagerCount = int(milestoneStats.OpenCount)
+ }
+
+ ctx.Data["Milestones"] = milestones
+ ctx.Data["Repos"] = showRepos
+ ctx.Data["Counts"] = counts
+ ctx.Data["MilestoneStats"] = milestoneStats
+ ctx.Data["SortType"] = sortType
+ ctx.Data["Keyword"] = keyword
+ if milestoneStats.Total() != totalMilestoneStats.Total() {
+ ctx.Data["RepoIDs"] = repoIDs
+ }
+ ctx.Data["IsShowClosed"] = isShowClosed
+
+ pager := context.NewPagination(pagerCount, setting.UI.IssuePagingNum, page, 5)
+ pager.AddParam(ctx, "q", "Keyword")
+ pager.AddParam(ctx, "repos", "RepoIDs")
+ pager.AddParam(ctx, "sort", "SortType")
+ pager.AddParam(ctx, "state", "State")
+ ctx.Data["Page"] = pager
+
+ ctx.HTML(http.StatusOK, tplMilestones)
+}
+
+// Pulls renders the user's pull request overview page
+func Pulls(ctx *context.Context) {
+ if models.UnitTypePullRequests.UnitGlobalDisabled() {
+ log.Debug("Pull request overview page not available as it is globally disabled.")
+ ctx.Status(404)
+ return
+ }
+
+ ctx.Data["Title"] = ctx.Tr("pull_requests")
+ ctx.Data["PageIsPulls"] = true
+ buildIssueOverview(ctx, models.UnitTypePullRequests)
+}
+
+// Issues renders the user's issues overview page
+func Issues(ctx *context.Context) {
+ if models.UnitTypeIssues.UnitGlobalDisabled() {
+ log.Debug("Issues overview page not available as it is globally disabled.")
+ ctx.Status(404)
+ return
+ }
+
+ ctx.Data["Title"] = ctx.Tr("issues")
+ ctx.Data["PageIsIssues"] = true
+ buildIssueOverview(ctx, models.UnitTypeIssues)
+}
+
+// Regexp for repos query
+var issueReposQueryPattern = regexp.MustCompile(`^\[\d+(,\d+)*,?\]$`)
+
+func buildIssueOverview(ctx *context.Context, unitType models.UnitType) {
+
+ // ----------------------------------------------------
+ // Determine user; can be either user or organization.
+ // Return with NotFound or ServerError if unsuccessful.
+ // ----------------------------------------------------
+
+ ctxUser := getDashboardContextUser(ctx)
+ if ctx.Written() {
+ return
+ }
+
+ var (
+ viewType string
+ sortType = ctx.Query("sort")
+ filterMode = models.FilterModeAll
+ )
+
+ // --------------------------------------------------------------------------------
+ // Distinguish User from Organization.
+ // Org:
+ // - Remember pre-determined viewType string for later. Will be posted to ctx.Data.
+ // Organization does not have view type and filter mode.
+ // User:
+ // - Use ctx.Query("type") to determine filterMode.
+ // The type is set when clicking for example "assigned to me" on the overview page.
+ // - Remember either this or a fallback. Will be posted to ctx.Data.
+ // --------------------------------------------------------------------------------
+
+ // TODO: distinguish during routing
+
+ viewType = ctx.Query("type")
+ switch viewType {
+ case "assigned":
+ filterMode = models.FilterModeAssign
+ case "created_by":
+ filterMode = models.FilterModeCreate
+ case "mentioned":
+ filterMode = models.FilterModeMention
+ case "review_requested":
+ filterMode = models.FilterModeReviewRequested
+ case "your_repositories": // filterMode already set to All
+ default:
+ viewType = "your_repositories"
+ }
+
+ // --------------------------------------------------------------------------
+ // Build opts (IssuesOptions), which contains filter information.
+ // Will eventually be used to retrieve issues relevant for the overview page.
+ // Note: Non-final states of opts are used in-between, namely for:
+ // - Keyword search
+ // - Count Issues by repo
+ // --------------------------------------------------------------------------
+
+ isPullList := unitType == models.UnitTypePullRequests
+ opts := &models.IssuesOptions{
+ IsPull: util.OptionalBoolOf(isPullList),
+ SortType: sortType,
+ IsArchived: util.OptionalBoolFalse,
+ }
+
+ // Get repository IDs where User/Org/Team has access.
+ var team *models.Team
+ if ctx.Org != nil {
+ team = ctx.Org.Team
+ }
+ userRepoIDs, err := getActiveUserRepoIDs(ctxUser, team, unitType)
+ if err != nil {
+ ctx.ServerError("userRepoIDs", err)
+ return
+ }
+
+ switch filterMode {
+ case models.FilterModeAll:
+ opts.RepoIDs = userRepoIDs
+ case models.FilterModeAssign:
+ opts.AssigneeID = ctx.User.ID
+ case models.FilterModeCreate:
+ opts.PosterID = ctx.User.ID
+ case models.FilterModeMention:
+ opts.MentionedID = ctx.User.ID
+ case models.FilterModeReviewRequested:
+ opts.ReviewRequestedID = ctx.User.ID
+ }
+
+ if ctxUser.IsOrganization() {
+ opts.RepoIDs = userRepoIDs
+ }
+
+ // keyword holds the search term entered into the search field.
+ keyword := strings.Trim(ctx.Query("q"), " ")
+ ctx.Data["Keyword"] = keyword
+
+ // Execute keyword search for issues.
+ // USING NON-FINAL STATE OF opts FOR A QUERY.
+ issueIDsFromSearch, err := issueIDsFromSearch(ctxUser, keyword, opts)
+ if err != nil {
+ ctx.ServerError("issueIDsFromSearch", err)
+ return
+ }
+
+ // Ensure no issues are returned if a keyword was provided that didn't match any issues.
+ var forceEmpty bool
+
+ if len(issueIDsFromSearch) > 0 {
+ opts.IssueIDs = issueIDsFromSearch
+ } else if len(keyword) > 0 {
+ forceEmpty = true
+ }
+
+ // Educated guess: Do or don't show closed issues.
+ isShowClosed := ctx.Query("state") == "closed"
+ opts.IsClosed = util.OptionalBoolOf(isShowClosed)
+
+ // Filter repos and count issues in them. Count will be used later.
+ // USING NON-FINAL STATE OF opts FOR A QUERY.
+ var issueCountByRepo map[int64]int64
+ if !forceEmpty {
+ issueCountByRepo, err = models.CountIssuesByRepo(opts)
+ if err != nil {
+ ctx.ServerError("CountIssuesByRepo", err)
+ return
+ }
+ }
+
+ // Make sure page number is at least 1. Will be posted to ctx.Data.
+ page := ctx.QueryInt("page")
+ if page <= 1 {
+ page = 1
+ }
+ opts.Page = page
+ opts.PageSize = setting.UI.IssuePagingNum
+
+ // Get IDs for labels (a filter option for issues/pulls).
+ // Required for IssuesOptions.
+ var labelIDs []int64
+ selectedLabels := ctx.Query("labels")
+ if len(selectedLabels) > 0 && selectedLabels != "0" {
+ labelIDs, err = base.StringsToInt64s(strings.Split(selectedLabels, ","))
+ if err != nil {
+ ctx.ServerError("StringsToInt64s", err)
+ return
+ }
+ }
+ opts.LabelIDs = labelIDs
+
+ // Parse ctx.Query("repos") and remember matched repo IDs for later.
+ // Gets set when clicking filters on the issues overview page.
+ repoIDs := getRepoIDs(ctx.Query("repos"))
+ if len(repoIDs) > 0 {
+ opts.RepoIDs = repoIDs
+ }
+
+ // ------------------------------
+ // Get issues as defined by opts.
+ // ------------------------------
+
+ // Slice of Issues that will be displayed on the overview page
+ // USING FINAL STATE OF opts FOR A QUERY.
+ var issues []*models.Issue
+ if !forceEmpty {
+ issues, err = models.Issues(opts)
+ if err != nil {
+ ctx.ServerError("Issues", err)
+ return
+ }
+ } else {
+ issues = []*models.Issue{}
+ }
+
+ // ----------------------------------
+ // Add repository pointers to Issues.
+ // ----------------------------------
+
+ // showReposMap maps repository IDs to their Repository pointers.
+ showReposMap, err := repoIDMap(ctxUser, issueCountByRepo, unitType)
+ if err != nil {
+ if models.IsErrRepoNotExist(err) {
+ ctx.NotFound("GetRepositoryByID", err)
+ return
+ }
+ ctx.ServerError("repoIDMap", err)
+ return
+ }
+
+ // a RepositoryList
+ showRepos := models.RepositoryListOfMap(showReposMap)
+ sort.Sort(showRepos)
+ if err = showRepos.LoadAttributes(); err != nil {
+ ctx.ServerError("LoadAttributes", err)
+ return
+ }
+
+ // maps pull request IDs to their CommitStatus. Will be posted to ctx.Data.
+ for _, issue := range issues {
+ issue.Repo = showReposMap[issue.RepoID]
+ }
+
+ commitStatus, err := pull_service.GetIssuesLastCommitStatus(issues)
+ if err != nil {
+ ctx.ServerError("GetIssuesLastCommitStatus", err)
+ return
+ }
+
+ // -------------------------------
+ // Fill stats to post to ctx.Data.
+ // -------------------------------
+
+ userIssueStatsOpts := models.UserIssueStatsOptions{
+ UserID: ctx.User.ID,
+ UserRepoIDs: userRepoIDs,
+ FilterMode: filterMode,
+ IsPull: isPullList,
+ IsClosed: isShowClosed,
+ IsArchived: util.OptionalBoolFalse,
+ LabelIDs: opts.LabelIDs,
+ }
+ if len(repoIDs) > 0 {
+ userIssueStatsOpts.UserRepoIDs = repoIDs
+ }
+ if ctxUser.IsOrganization() {
+ userIssueStatsOpts.RepoIDs = userRepoIDs
+ }
+ userIssueStats, err := models.GetUserIssueStats(userIssueStatsOpts)
+ if err != nil {
+ ctx.ServerError("GetUserIssueStats User", err)
+ return
+ }
+
+ var shownIssueStats *models.IssueStats
+ if !forceEmpty {
+ statsOpts := models.UserIssueStatsOptions{
+ UserID: ctx.User.ID,
+ UserRepoIDs: userRepoIDs,
+ FilterMode: filterMode,
+ IsPull: isPullList,
+ IsClosed: isShowClosed,
+ IssueIDs: issueIDsFromSearch,
+ IsArchived: util.OptionalBoolFalse,
+ LabelIDs: opts.LabelIDs,
+ }
+ if len(repoIDs) > 0 {
+ statsOpts.RepoIDs = repoIDs
+ } else if ctxUser.IsOrganization() {
+ statsOpts.RepoIDs = userRepoIDs
+ }
+ shownIssueStats, err = models.GetUserIssueStats(statsOpts)
+ if err != nil {
+ ctx.ServerError("GetUserIssueStats Shown", err)
+ return
+ }
+ } else {
+ shownIssueStats = &models.IssueStats{}
+ }
+
+ var allIssueStats *models.IssueStats
+ if !forceEmpty {
+ allIssueStatsOpts := models.UserIssueStatsOptions{
+ UserID: ctx.User.ID,
+ UserRepoIDs: userRepoIDs,
+ FilterMode: filterMode,
+ IsPull: isPullList,
+ IsClosed: isShowClosed,
+ IssueIDs: issueIDsFromSearch,
+ IsArchived: util.OptionalBoolFalse,
+ LabelIDs: opts.LabelIDs,
+ }
+ if ctxUser.IsOrganization() {
+ allIssueStatsOpts.RepoIDs = userRepoIDs
+ }
+ allIssueStats, err = models.GetUserIssueStats(allIssueStatsOpts)
+ if err != nil {
+ ctx.ServerError("GetUserIssueStats All", err)
+ return
+ }
+ } else {
+ allIssueStats = &models.IssueStats{}
+ }
+
+ // Will be posted to ctx.Data.
+ var shownIssues int
+ if !isShowClosed {
+ shownIssues = int(shownIssueStats.OpenCount)
+ ctx.Data["TotalIssueCount"] = int(allIssueStats.OpenCount)
+ } else {
+ shownIssues = int(shownIssueStats.ClosedCount)
+ ctx.Data["TotalIssueCount"] = int(allIssueStats.ClosedCount)
+ }
+
+ ctx.Data["IsShowClosed"] = isShowClosed
+
+ ctx.Data["IssueRefEndNames"], ctx.Data["IssueRefURLs"] =
+ issue_service.GetRefEndNamesAndURLs(issues, ctx.Query("RepoLink"))
+
+ ctx.Data["Issues"] = issues
+
+ approvalCounts, err := models.IssueList(issues).GetApprovalCounts()
+ if err != nil {
+ ctx.ServerError("ApprovalCounts", err)
+ return
+ }
+ ctx.Data["ApprovalCounts"] = func(issueID int64, typ string) int64 {
+ counts, ok := approvalCounts[issueID]
+ if !ok || len(counts) == 0 {
+ return 0
+ }
+ reviewTyp := models.ReviewTypeApprove
+ if typ == "reject" {
+ reviewTyp = models.ReviewTypeReject
+ } else if typ == "waiting" {
+ reviewTyp = models.ReviewTypeRequest
+ }
+ for _, count := range counts {
+ if count.Type == reviewTyp {
+ return count.Count
+ }
+ }
+ return 0
+ }
+ ctx.Data["CommitStatus"] = commitStatus
+ ctx.Data["Repos"] = showRepos
+ ctx.Data["Counts"] = issueCountByRepo
+ ctx.Data["IssueStats"] = userIssueStats
+ ctx.Data["ShownIssueStats"] = shownIssueStats
+ ctx.Data["ViewType"] = viewType
+ ctx.Data["SortType"] = sortType
+ ctx.Data["RepoIDs"] = repoIDs
+ ctx.Data["IsShowClosed"] = isShowClosed
+ ctx.Data["SelectLabels"] = selectedLabels
+
+ if isShowClosed {
+ ctx.Data["State"] = "closed"
+ } else {
+ ctx.Data["State"] = "open"
+ }
+
+ // Convert []int64 to string
+ json := jsoniter.ConfigCompatibleWithStandardLibrary
+ reposParam, _ := json.Marshal(repoIDs)
+
+ ctx.Data["ReposParam"] = string(reposParam)
+
+ pager := context.NewPagination(shownIssues, setting.UI.IssuePagingNum, page, 5)
+ pager.AddParam(ctx, "q", "Keyword")
+ pager.AddParam(ctx, "type", "ViewType")
+ pager.AddParam(ctx, "repos", "ReposParam")
+ pager.AddParam(ctx, "sort", "SortType")
+ pager.AddParam(ctx, "state", "State")
+ pager.AddParam(ctx, "labels", "SelectLabels")
+ pager.AddParam(ctx, "milestone", "MilestoneID")
+ pager.AddParam(ctx, "assignee", "AssigneeID")
+ ctx.Data["Page"] = pager
+
+ ctx.HTML(http.StatusOK, tplIssues)
+}
+
+func getRepoIDs(reposQuery string) []int64 {
+ if len(reposQuery) == 0 || reposQuery == "[]" {
+ return []int64{}
+ }
+ if !issueReposQueryPattern.MatchString(reposQuery) {
+ log.Warn("issueReposQueryPattern does not match query")
+ return []int64{}
+ }
+
+ var repoIDs []int64
+ // remove "[" and "]" from string
+ reposQuery = reposQuery[1 : len(reposQuery)-1]
+ //for each ID (delimiter ",") add to int to repoIDs
+ for _, rID := range strings.Split(reposQuery, ",") {
+ // Ensure nonempty string entries
+ if rID != "" && rID != "0" {
+ rIDint64, err := strconv.ParseInt(rID, 10, 64)
+ if err == nil {
+ repoIDs = append(repoIDs, rIDint64)
+ }
+ }
+ }
+
+ return repoIDs
+}
+
+func getActiveUserRepoIDs(ctxUser *models.User, team *models.Team, unitType models.UnitType) ([]int64, error) {
+ var userRepoIDs []int64
+ var err error
+
+ if ctxUser.IsOrganization() {
+ userRepoIDs, err = getActiveTeamOrOrgRepoIds(ctxUser, team, unitType)
+ if err != nil {
+ return nil, fmt.Errorf("orgRepoIds: %v", err)
+ }
+ } else {
+ userRepoIDs, err = ctxUser.GetActiveAccessRepoIDs(unitType)
+ if err != nil {
+ return nil, fmt.Errorf("ctxUser.GetAccessRepoIDs: %v", err)
+ }
+ }
+
+ if len(userRepoIDs) == 0 {
+ userRepoIDs = []int64{-1}
+ }
+
+ return userRepoIDs, nil
+}
+
+// getActiveTeamOrOrgRepoIds gets RepoIDs for ctxUser as Organization.
+// Should be called if and only if ctxUser.IsOrganization == true.
+func getActiveTeamOrOrgRepoIds(ctxUser *models.User, team *models.Team, unitType models.UnitType) ([]int64, error) {
+ var orgRepoIDs []int64
+ var err error
+ var env models.AccessibleReposEnvironment
+
+ if team != nil {
+ env = ctxUser.AccessibleTeamReposEnv(team)
+ } else {
+ env, err = ctxUser.AccessibleReposEnv(ctxUser.ID)
+ if err != nil {
+ return nil, fmt.Errorf("AccessibleReposEnv: %v", err)
+ }
+ }
+ orgRepoIDs, err = env.RepoIDs(1, ctxUser.NumRepos)
+ if err != nil {
+ return nil, fmt.Errorf("env.RepoIDs: %v", err)
+ }
+ orgRepoIDs, err = models.FilterOutRepoIdsWithoutUnitAccess(ctxUser, orgRepoIDs, unitType)
+ if err != nil {
+ return nil, fmt.Errorf("FilterOutRepoIdsWithoutUnitAccess: %v", err)
+ }
+
+ return orgRepoIDs, nil
+}
+
+func issueIDsFromSearch(ctxUser *models.User, keyword string, opts *models.IssuesOptions) ([]int64, error) {
+ if len(keyword) == 0 {
+ return []int64{}, nil
+ }
+
+ searchRepoIDs, err := models.GetRepoIDsForIssuesOptions(opts, ctxUser)
+ if err != nil {
+ return nil, fmt.Errorf("GetRepoIDsForIssuesOptions: %v", err)
+ }
+ issueIDsFromSearch, err := issue_indexer.SearchIssuesByKeyword(searchRepoIDs, keyword)
+ if err != nil {
+ return nil, fmt.Errorf("SearchIssuesByKeyword: %v", err)
+ }
+
+ return issueIDsFromSearch, nil
+}
+
+func repoIDMap(ctxUser *models.User, issueCountByRepo map[int64]int64, unitType models.UnitType) (map[int64]*models.Repository, error) {
+ repoByID := make(map[int64]*models.Repository, len(issueCountByRepo))
+ for id := range issueCountByRepo {
+ if id <= 0 {
+ continue
+ }
+ if _, ok := repoByID[id]; !ok {
+ repo, err := models.GetRepositoryByID(id)
+ if models.IsErrRepoNotExist(err) {
+ return nil, err
+ } else if err != nil {
+ return nil, fmt.Errorf("GetRepositoryByID: [%d]%v", id, err)
+ }
+ repoByID[id] = repo
+ }
+ repo := repoByID[id]
+
+ // Check if user has access to given repository.
+ perm, err := models.GetUserRepoPermission(repo, ctxUser)
+ if err != nil {
+ return nil, fmt.Errorf("GetUserRepoPermission: [%d]%v", id, err)
+ }
+ if !perm.CanRead(unitType) {
+ log.Debug("User created Issues in Repository which they no longer have access to: [%d]", id)
+ }
+ }
+ return repoByID, nil
+}
+
+// ShowSSHKeys output all the ssh keys of user by uid
+func ShowSSHKeys(ctx *context.Context, uid int64) {
+ keys, err := models.ListPublicKeys(uid, models.ListOptions{})
+ if err != nil {
+ ctx.ServerError("ListPublicKeys", err)
+ return
+ }
+
+ var buf bytes.Buffer
+ for i := range keys {
+ buf.WriteString(keys[i].OmitEmail())
+ buf.WriteString("\n")
+ }
+ ctx.PlainText(200, buf.Bytes())
+}
+
+// ShowGPGKeys output all the public GPG keys of user by uid
+func ShowGPGKeys(ctx *context.Context, uid int64) {
+ keys, err := models.ListGPGKeys(uid, models.ListOptions{})
+ if err != nil {
+ ctx.ServerError("ListGPGKeys", err)
+ return
+ }
+ entities := make([]*openpgp.Entity, 0)
+ failedEntitiesID := make([]string, 0)
+ for _, k := range keys {
+ e, err := models.GPGKeyToEntity(k)
+ if err != nil {
+ if models.IsErrGPGKeyImportNotExist(err) {
+ failedEntitiesID = append(failedEntitiesID, k.KeyID)
+ continue //Skip previous import without backup of imported armored key
+ }
+ ctx.ServerError("ShowGPGKeys", err)
+ return
+ }
+ entities = append(entities, e)
+ }
+ var buf bytes.Buffer
+
+ headers := make(map[string]string)
+ if len(failedEntitiesID) > 0 { //If some key need re-import to be exported
+ headers["Note"] = fmt.Sprintf("The keys with the following IDs couldn't be exported and need to be reuploaded %s", strings.Join(failedEntitiesID, ", "))
+ }
+ writer, _ := armor.Encode(&buf, "PGP PUBLIC KEY BLOCK", headers)
+ for _, e := range entities {
+ err = e.Serialize(writer) //TODO find why key are exported with a different cipherTypeByte as original (should not be blocking but strange)
+ if err != nil {
+ ctx.ServerError("ShowGPGKeys", err)
+ return
+ }
+ }
+ writer.Close()
+ ctx.PlainText(200, buf.Bytes())
+}
+
+// Email2User show user page via email
+func Email2User(ctx *context.Context) {
+ u, err := models.GetUserByEmail(ctx.Query("email"))
+ if err != nil {
+ if models.IsErrUserNotExist(err) {
+ ctx.NotFound("GetUserByEmail", err)
+ } else {
+ ctx.ServerError("GetUserByEmail", err)
+ }
+ return
+ }
+ ctx.Redirect(setting.AppSubURL + "/user/" + u.Name)
+}
diff --git a/routers/web/user/home_test.go b/routers/web/user/home_test.go
new file mode 100644
index 0000000000..b0109c354f
--- /dev/null
+++ b/routers/web/user/home_test.go
@@ -0,0 +1,118 @@
+// Copyright 2017 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
+
+import (
+ "net/http"
+ "testing"
+
+ "code.gitea.io/gitea/models"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/test"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestArchivedIssues(t *testing.T) {
+ // Arrange
+ setting.UI.IssuePagingNum = 1
+ assert.NoError(t, models.LoadFixtures())
+
+ ctx := test.MockContext(t, "issues")
+ test.LoadUser(t, ctx, 30)
+ ctx.Req.Form.Set("state", "open")
+
+ // Assume: User 30 has access to two Repos with Issues, one of the Repos being archived.
+ repos, _, _ := models.GetUserRepositories(&models.SearchRepoOptions{Actor: ctx.User})
+ assert.Len(t, repos, 2)
+ IsArchived := make(map[int64]bool)
+ NumIssues := make(map[int64]int)
+ for _, repo := range repos {
+ IsArchived[repo.ID] = repo.IsArchived
+ NumIssues[repo.ID] = repo.NumIssues
+ }
+ assert.False(t, IsArchived[50])
+ assert.EqualValues(t, 1, NumIssues[50])
+ assert.True(t, IsArchived[51])
+ assert.EqualValues(t, 1, NumIssues[51])
+
+ // Act
+ Issues(ctx)
+
+ // Assert: One Issue (ID 30) from one Repo (ID 50) is retrieved, while nothing from archived Repo 51 is retrieved
+ assert.EqualValues(t, http.StatusOK, ctx.Resp.Status())
+
+ assert.EqualValues(t, map[int64]int64{50: 1}, ctx.Data["Counts"])
+ assert.Len(t, ctx.Data["Issues"], 1)
+ assert.Len(t, ctx.Data["Repos"], 1)
+}
+
+func TestIssues(t *testing.T) {
+ setting.UI.IssuePagingNum = 1
+ assert.NoError(t, models.LoadFixtures())
+
+ ctx := test.MockContext(t, "issues")
+ test.LoadUser(t, ctx, 2)
+ ctx.Req.Form.Set("state", "closed")
+ Issues(ctx)
+ assert.EqualValues(t, http.StatusOK, ctx.Resp.Status())
+
+ assert.EqualValues(t, map[int64]int64{1: 1, 2: 1}, ctx.Data["Counts"])
+ assert.EqualValues(t, true, ctx.Data["IsShowClosed"])
+ assert.Len(t, ctx.Data["Issues"], 1)
+ assert.Len(t, ctx.Data["Repos"], 2)
+}
+
+func TestPulls(t *testing.T) {
+ setting.UI.IssuePagingNum = 20
+ assert.NoError(t, models.LoadFixtures())
+
+ ctx := test.MockContext(t, "pulls")
+ test.LoadUser(t, ctx, 2)
+ ctx.Req.Form.Set("state", "open")
+ Pulls(ctx)
+ assert.EqualValues(t, http.StatusOK, ctx.Resp.Status())
+
+ assert.Len(t, ctx.Data["Issues"], 3)
+}
+
+func TestMilestones(t *testing.T) {
+ setting.UI.IssuePagingNum = 1
+ assert.NoError(t, models.LoadFixtures())
+
+ ctx := test.MockContext(t, "milestones")
+ test.LoadUser(t, ctx, 2)
+ ctx.SetParams("sort", "issues")
+ ctx.Req.Form.Set("state", "closed")
+ ctx.Req.Form.Set("sort", "furthestduedate")
+ Milestones(ctx)
+ assert.EqualValues(t, http.StatusOK, ctx.Resp.Status())
+ assert.EqualValues(t, map[int64]int64{1: 1}, ctx.Data["Counts"])
+ assert.EqualValues(t, true, ctx.Data["IsShowClosed"])
+ assert.EqualValues(t, "furthestduedate", ctx.Data["SortType"])
+ assert.EqualValues(t, 1, ctx.Data["Total"])
+ assert.Len(t, ctx.Data["Milestones"], 1)
+ assert.Len(t, ctx.Data["Repos"], 2) // both repo 42 and 1 have milestones and both are owned by user 2
+}
+
+func TestMilestonesForSpecificRepo(t *testing.T) {
+ setting.UI.IssuePagingNum = 1
+ assert.NoError(t, models.LoadFixtures())
+
+ ctx := test.MockContext(t, "milestones")
+ test.LoadUser(t, ctx, 2)
+ ctx.SetParams("sort", "issues")
+ ctx.SetParams("repo", "1")
+ ctx.Req.Form.Set("state", "closed")
+ ctx.Req.Form.Set("sort", "furthestduedate")
+ Milestones(ctx)
+ assert.EqualValues(t, http.StatusOK, ctx.Resp.Status())
+ assert.EqualValues(t, map[int64]int64{1: 1}, ctx.Data["Counts"])
+ assert.EqualValues(t, true, ctx.Data["IsShowClosed"])
+ assert.EqualValues(t, "furthestduedate", ctx.Data["SortType"])
+ assert.EqualValues(t, 1, ctx.Data["Total"])
+ assert.Len(t, ctx.Data["Milestones"], 1)
+ assert.Len(t, ctx.Data["Repos"], 2) // both repo 42 and 1 have milestones and both are owned by user 2
+}
diff --git a/routers/web/user/main_test.go b/routers/web/user/main_test.go
new file mode 100644
index 0000000000..be17dd1f31
--- /dev/null
+++ b/routers/web/user/main_test.go
@@ -0,0 +1,16 @@
+// Copyright 2017 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
+
+import (
+ "path/filepath"
+ "testing"
+
+ "code.gitea.io/gitea/models"
+)
+
+func TestMain(m *testing.M) {
+ models.MainTest(m, filepath.Join("..", "..", ".."))
+}
diff --git a/routers/web/user/notification.go b/routers/web/user/notification.go
new file mode 100644
index 0000000000..523e945db9
--- /dev/null
+++ b/routers/web/user/notification.go
@@ -0,0 +1,192 @@
+// Copyright 2019 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
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "strconv"
+ "strings"
+
+ "code.gitea.io/gitea/models"
+ "code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/context"
+ "code.gitea.io/gitea/modules/setting"
+)
+
+const (
+ tplNotification base.TplName = "user/notification/notification"
+ tplNotificationDiv base.TplName = "user/notification/notification_div"
+)
+
+// GetNotificationCount is the middleware that sets the notification count in the context
+func GetNotificationCount(c *context.Context) {
+ if strings.HasPrefix(c.Req.URL.Path, "/api") {
+ return
+ }
+
+ if !c.IsSigned {
+ return
+ }
+
+ c.Data["NotificationUnreadCount"] = func() int64 {
+ count, err := models.GetNotificationCount(c.User, models.NotificationStatusUnread)
+ if err != nil {
+ c.ServerError("GetNotificationCount", err)
+ return -1
+ }
+
+ return count
+ }
+}
+
+// Notifications is the notifications page
+func Notifications(c *context.Context) {
+ getNotifications(c)
+ if c.Written() {
+ return
+ }
+ if c.QueryBool("div-only") {
+ c.HTML(http.StatusOK, tplNotificationDiv)
+ return
+ }
+ c.HTML(http.StatusOK, tplNotification)
+}
+
+func getNotifications(c *context.Context) {
+ var (
+ keyword = strings.Trim(c.Query("q"), " ")
+ status models.NotificationStatus
+ page = c.QueryInt("page")
+ perPage = c.QueryInt("perPage")
+ )
+ if page < 1 {
+ page = 1
+ }
+ if perPage < 1 {
+ perPage = 20
+ }
+
+ switch keyword {
+ case "read":
+ status = models.NotificationStatusRead
+ default:
+ status = models.NotificationStatusUnread
+ }
+
+ total, err := models.GetNotificationCount(c.User, status)
+ if err != nil {
+ c.ServerError("ErrGetNotificationCount", err)
+ return
+ }
+
+ // redirect to last page if request page is more than total pages
+ pager := context.NewPagination(int(total), perPage, page, 5)
+ if pager.Paginater.Current() < page {
+ c.Redirect(fmt.Sprintf("/notifications?q=%s&page=%d", c.Query("q"), pager.Paginater.Current()))
+ return
+ }
+
+ statuses := []models.NotificationStatus{status, models.NotificationStatusPinned}
+ notifications, err := models.NotificationsForUser(c.User, statuses, page, perPage)
+ if err != nil {
+ c.ServerError("ErrNotificationsForUser", err)
+ return
+ }
+
+ failCount := 0
+
+ repos, failures, err := notifications.LoadRepos()
+ if err != nil {
+ c.ServerError("LoadRepos", err)
+ return
+ }
+ notifications = notifications.Without(failures)
+ if err := repos.LoadAttributes(); err != nil {
+ c.ServerError("LoadAttributes", err)
+ return
+ }
+ failCount += len(failures)
+
+ failures, err = notifications.LoadIssues()
+ if err != nil {
+ c.ServerError("LoadIssues", err)
+ return
+ }
+ notifications = notifications.Without(failures)
+ failCount += len(failures)
+
+ failures, err = notifications.LoadComments()
+ if err != nil {
+ c.ServerError("LoadComments", err)
+ return
+ }
+ notifications = notifications.Without(failures)
+ failCount += len(failures)
+
+ if failCount > 0 {
+ c.Flash.Error(fmt.Sprintf("ERROR: %d notifications were removed due to missing parts - check the logs", failCount))
+ }
+
+ c.Data["Title"] = c.Tr("notifications")
+ c.Data["Keyword"] = keyword
+ c.Data["Status"] = status
+ c.Data["Notifications"] = notifications
+
+ pager.SetDefaultParams(c)
+ c.Data["Page"] = pager
+}
+
+// NotificationStatusPost is a route for changing the status of a notification
+func NotificationStatusPost(c *context.Context) {
+ var (
+ notificationID, _ = strconv.ParseInt(c.Req.PostFormValue("notification_id"), 10, 64)
+ statusStr = c.Req.PostFormValue("status")
+ status models.NotificationStatus
+ )
+
+ switch statusStr {
+ case "read":
+ status = models.NotificationStatusRead
+ case "unread":
+ status = models.NotificationStatusUnread
+ case "pinned":
+ status = models.NotificationStatusPinned
+ default:
+ c.ServerError("InvalidNotificationStatus", errors.New("Invalid notification status"))
+ return
+ }
+
+ if err := models.SetNotificationStatus(notificationID, c.User, status); err != nil {
+ c.ServerError("SetNotificationStatus", err)
+ return
+ }
+
+ if !c.QueryBool("noredirect") {
+ url := fmt.Sprintf("%s/notifications?page=%s", setting.AppSubURL, c.Query("page"))
+ c.Redirect(url, http.StatusSeeOther)
+ }
+
+ getNotifications(c)
+ if c.Written() {
+ return
+ }
+ c.Data["Link"] = setting.AppURL + "notifications"
+
+ c.HTML(http.StatusOK, tplNotificationDiv)
+}
+
+// NotificationPurgePost is a route for 'purging' the list of notifications - marking all unread as read
+func NotificationPurgePost(c *context.Context) {
+ err := models.UpdateNotificationStatuses(c.User, models.NotificationStatusUnread, models.NotificationStatusRead)
+ if err != nil {
+ c.ServerError("ErrUpdateNotificationStatuses", err)
+ return
+ }
+
+ url := fmt.Sprintf("%s/notifications", setting.AppSubURL)
+ c.Redirect(url, http.StatusSeeOther)
+}
diff --git a/routers/web/user/oauth.go b/routers/web/user/oauth.go
new file mode 100644
index 0000000000..3ef5a56c01
--- /dev/null
+++ b/routers/web/user/oauth.go
@@ -0,0 +1,646 @@
+// Copyright 2019 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
+
+import (
+ "encoding/base64"
+ "fmt"
+ "html"
+ "net/http"
+ "net/url"
+ "strings"
+
+ "code.gitea.io/gitea/models"
+ "code.gitea.io/gitea/modules/auth/sso"
+ "code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/context"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/timeutil"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/services/forms"
+
+ "gitea.com/go-chi/binding"
+ "github.com/dgrijalva/jwt-go"
+)
+
+const (
+ tplGrantAccess base.TplName = "user/auth/grant"
+ tplGrantError base.TplName = "user/auth/grant_error"
+)
+
+// TODO move error and responses to SDK or models
+
+// AuthorizeErrorCode represents an error code specified in RFC 6749
+type AuthorizeErrorCode string
+
+const (
+ // ErrorCodeInvalidRequest represents the according error in RFC 6749
+ ErrorCodeInvalidRequest AuthorizeErrorCode = "invalid_request"
+ // ErrorCodeUnauthorizedClient represents the according error in RFC 6749
+ ErrorCodeUnauthorizedClient AuthorizeErrorCode = "unauthorized_client"
+ // ErrorCodeAccessDenied represents the according error in RFC 6749
+ ErrorCodeAccessDenied AuthorizeErrorCode = "access_denied"
+ // ErrorCodeUnsupportedResponseType represents the according error in RFC 6749
+ ErrorCodeUnsupportedResponseType AuthorizeErrorCode = "unsupported_response_type"
+ // ErrorCodeInvalidScope represents the according error in RFC 6749
+ ErrorCodeInvalidScope AuthorizeErrorCode = "invalid_scope"
+ // ErrorCodeServerError represents the according error in RFC 6749
+ ErrorCodeServerError AuthorizeErrorCode = "server_error"
+ // ErrorCodeTemporaryUnavailable represents the according error in RFC 6749
+ ErrorCodeTemporaryUnavailable AuthorizeErrorCode = "temporarily_unavailable"
+)
+
+// AuthorizeError represents an error type specified in RFC 6749
+type AuthorizeError struct {
+ ErrorCode AuthorizeErrorCode `json:"error" form:"error"`
+ ErrorDescription string
+ State string
+}
+
+// Error returns the error message
+func (err AuthorizeError) Error() string {
+ return fmt.Sprintf("%s: %s", err.ErrorCode, err.ErrorDescription)
+}
+
+// AccessTokenErrorCode represents an error code specified in RFC 6749
+type AccessTokenErrorCode string
+
+const (
+ // AccessTokenErrorCodeInvalidRequest represents an error code specified in RFC 6749
+ AccessTokenErrorCodeInvalidRequest AccessTokenErrorCode = "invalid_request"
+ // AccessTokenErrorCodeInvalidClient represents an error code specified in RFC 6749
+ AccessTokenErrorCodeInvalidClient = "invalid_client"
+ // AccessTokenErrorCodeInvalidGrant represents an error code specified in RFC 6749
+ AccessTokenErrorCodeInvalidGrant = "invalid_grant"
+ // AccessTokenErrorCodeUnauthorizedClient represents an error code specified in RFC 6749
+ AccessTokenErrorCodeUnauthorizedClient = "unauthorized_client"
+ // AccessTokenErrorCodeUnsupportedGrantType represents an error code specified in RFC 6749
+ AccessTokenErrorCodeUnsupportedGrantType = "unsupported_grant_type"
+ // AccessTokenErrorCodeInvalidScope represents an error code specified in RFC 6749
+ AccessTokenErrorCodeInvalidScope = "invalid_scope"
+)
+
+// AccessTokenError represents an error response specified in RFC 6749
+type AccessTokenError struct {
+ ErrorCode AccessTokenErrorCode `json:"error" form:"error"`
+ ErrorDescription string `json:"error_description"`
+}
+
+// Error returns the error message
+func (err AccessTokenError) Error() string {
+ return fmt.Sprintf("%s: %s", err.ErrorCode, err.ErrorDescription)
+}
+
+// BearerTokenErrorCode represents an error code specified in RFC 6750
+type BearerTokenErrorCode string
+
+const (
+ // BearerTokenErrorCodeInvalidRequest represents an error code specified in RFC 6750
+ BearerTokenErrorCodeInvalidRequest BearerTokenErrorCode = "invalid_request"
+ // BearerTokenErrorCodeInvalidToken represents an error code specified in RFC 6750
+ BearerTokenErrorCodeInvalidToken BearerTokenErrorCode = "invalid_token"
+ // BearerTokenErrorCodeInsufficientScope represents an error code specified in RFC 6750
+ BearerTokenErrorCodeInsufficientScope BearerTokenErrorCode = "insufficient_scope"
+)
+
+// BearerTokenError represents an error response specified in RFC 6750
+type BearerTokenError struct {
+ ErrorCode BearerTokenErrorCode `json:"error" form:"error"`
+ ErrorDescription string `json:"error_description"`
+}
+
+// TokenType specifies the kind of token
+type TokenType string
+
+const (
+ // TokenTypeBearer represents a token type specified in RFC 6749
+ TokenTypeBearer TokenType = "bearer"
+ // TokenTypeMAC represents a token type specified in RFC 6749
+ TokenTypeMAC = "mac"
+)
+
+// AccessTokenResponse represents a successful access token response
+type AccessTokenResponse struct {
+ AccessToken string `json:"access_token"`
+ TokenType TokenType `json:"token_type"`
+ ExpiresIn int64 `json:"expires_in"`
+ RefreshToken string `json:"refresh_token"`
+ IDToken string `json:"id_token,omitempty"`
+}
+
+func newAccessTokenResponse(grant *models.OAuth2Grant, clientSecret string) (*AccessTokenResponse, *AccessTokenError) {
+ if setting.OAuth2.InvalidateRefreshTokens {
+ if err := grant.IncreaseCounter(); err != nil {
+ return nil, &AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidGrant,
+ ErrorDescription: "cannot increase the grant counter",
+ }
+ }
+ }
+ // generate access token to access the API
+ expirationDate := timeutil.TimeStampNow().Add(setting.OAuth2.AccessTokenExpirationTime)
+ accessToken := &models.OAuth2Token{
+ GrantID: grant.ID,
+ Type: models.TypeAccessToken,
+ StandardClaims: jwt.StandardClaims{
+ ExpiresAt: expirationDate.AsTime().Unix(),
+ },
+ }
+ signedAccessToken, err := accessToken.SignToken()
+ if err != nil {
+ return nil, &AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidRequest,
+ ErrorDescription: "cannot sign token",
+ }
+ }
+
+ // generate refresh token to request an access token after it expired later
+ refreshExpirationDate := timeutil.TimeStampNow().Add(setting.OAuth2.RefreshTokenExpirationTime * 60 * 60).AsTime().Unix()
+ refreshToken := &models.OAuth2Token{
+ GrantID: grant.ID,
+ Counter: grant.Counter,
+ Type: models.TypeRefreshToken,
+ StandardClaims: jwt.StandardClaims{
+ ExpiresAt: refreshExpirationDate,
+ },
+ }
+ signedRefreshToken, err := refreshToken.SignToken()
+ if err != nil {
+ return nil, &AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidRequest,
+ ErrorDescription: "cannot sign token",
+ }
+ }
+
+ // generate OpenID Connect id_token
+ signedIDToken := ""
+ if grant.ScopeContains("openid") {
+ app, err := models.GetOAuth2ApplicationByID(grant.ApplicationID)
+ if err != nil {
+ return nil, &AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidRequest,
+ ErrorDescription: "cannot find application",
+ }
+ }
+ idToken := &models.OIDCToken{
+ StandardClaims: jwt.StandardClaims{
+ ExpiresAt: expirationDate.AsTime().Unix(),
+ Issuer: setting.AppURL,
+ Audience: app.ClientID,
+ Subject: fmt.Sprint(grant.UserID),
+ },
+ Nonce: grant.Nonce,
+ }
+ signedIDToken, err = idToken.SignToken(clientSecret)
+ if err != nil {
+ return nil, &AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidRequest,
+ ErrorDescription: "cannot sign token",
+ }
+ }
+ }
+
+ return &AccessTokenResponse{
+ AccessToken: signedAccessToken,
+ TokenType: TokenTypeBearer,
+ ExpiresIn: setting.OAuth2.AccessTokenExpirationTime,
+ RefreshToken: signedRefreshToken,
+ IDToken: signedIDToken,
+ }, nil
+}
+
+type userInfoResponse struct {
+ Sub string `json:"sub"`
+ Name string `json:"name"`
+ Username string `json:"preferred_username"`
+ Email string `json:"email"`
+ Picture string `json:"picture"`
+}
+
+// InfoOAuth manages request for userinfo endpoint
+func InfoOAuth(ctx *context.Context) {
+ header := ctx.Req.Header.Get("Authorization")
+ auths := strings.Fields(header)
+ if len(auths) != 2 || auths[0] != "Bearer" {
+ ctx.HandleText(http.StatusUnauthorized, "no valid auth token authorization")
+ return
+ }
+ uid := sso.CheckOAuthAccessToken(auths[1])
+ if uid == 0 {
+ handleBearerTokenError(ctx, BearerTokenError{
+ ErrorCode: BearerTokenErrorCodeInvalidToken,
+ ErrorDescription: "Access token not assigned to any user",
+ })
+ return
+ }
+ authUser, err := models.GetUserByID(uid)
+ if err != nil {
+ ctx.ServerError("GetUserByID", err)
+ return
+ }
+ response := &userInfoResponse{
+ Sub: fmt.Sprint(authUser.ID),
+ Name: authUser.FullName,
+ Username: authUser.Name,
+ Email: authUser.Email,
+ Picture: authUser.AvatarLink(),
+ }
+ ctx.JSON(http.StatusOK, response)
+}
+
+// AuthorizeOAuth manages authorize requests
+func AuthorizeOAuth(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.AuthorizationForm)
+ errs := binding.Errors{}
+ errs = form.Validate(ctx.Req, errs)
+ if len(errs) > 0 {
+ errstring := ""
+ for _, e := range errs {
+ errstring += e.Error() + "\n"
+ }
+ ctx.ServerError("AuthorizeOAuth: Validate: ", fmt.Errorf("errors occurred during validation: %s", errstring))
+ return
+ }
+
+ app, err := models.GetOAuth2ApplicationByClientID(form.ClientID)
+ if err != nil {
+ if models.IsErrOauthClientIDInvalid(err) {
+ handleAuthorizeError(ctx, AuthorizeError{
+ ErrorCode: ErrorCodeUnauthorizedClient,
+ ErrorDescription: "Client ID not registered",
+ State: form.State,
+ }, "")
+ return
+ }
+ ctx.ServerError("GetOAuth2ApplicationByClientID", err)
+ return
+ }
+ if err := app.LoadUser(); err != nil {
+ ctx.ServerError("LoadUser", err)
+ return
+ }
+
+ if !app.ContainsRedirectURI(form.RedirectURI) {
+ handleAuthorizeError(ctx, AuthorizeError{
+ ErrorCode: ErrorCodeInvalidRequest,
+ ErrorDescription: "Unregistered Redirect URI",
+ State: form.State,
+ }, "")
+ return
+ }
+
+ if form.ResponseType != "code" {
+ handleAuthorizeError(ctx, AuthorizeError{
+ ErrorCode: ErrorCodeUnsupportedResponseType,
+ ErrorDescription: "Only code response type is supported.",
+ State: form.State,
+ }, form.RedirectURI)
+ return
+ }
+
+ // pkce support
+ switch form.CodeChallengeMethod {
+ case "S256":
+ case "plain":
+ if err := ctx.Session.Set("CodeChallengeMethod", form.CodeChallengeMethod); err != nil {
+ handleAuthorizeError(ctx, AuthorizeError{
+ ErrorCode: ErrorCodeServerError,
+ ErrorDescription: "cannot set code challenge method",
+ State: form.State,
+ }, form.RedirectURI)
+ return
+ }
+ if err := ctx.Session.Set("CodeChallengeMethod", form.CodeChallenge); err != nil {
+ handleAuthorizeError(ctx, AuthorizeError{
+ ErrorCode: ErrorCodeServerError,
+ ErrorDescription: "cannot set code challenge",
+ State: form.State,
+ }, form.RedirectURI)
+ return
+ }
+ // Here we're just going to try to release the session early
+ if err := ctx.Session.Release(); err != nil {
+ // we'll tolerate errors here as they *should* get saved elsewhere
+ log.Error("Unable to save changes to the session: %v", err)
+ }
+ case "":
+ break
+ default:
+ handleAuthorizeError(ctx, AuthorizeError{
+ ErrorCode: ErrorCodeInvalidRequest,
+ ErrorDescription: "unsupported code challenge method",
+ State: form.State,
+ }, form.RedirectURI)
+ return
+ }
+
+ grant, err := app.GetGrantByUserID(ctx.User.ID)
+ if err != nil {
+ handleServerError(ctx, form.State, form.RedirectURI)
+ return
+ }
+
+ // Redirect if user already granted access
+ if grant != nil {
+ code, err := grant.GenerateNewAuthorizationCode(form.RedirectURI, form.CodeChallenge, form.CodeChallengeMethod)
+ if err != nil {
+ handleServerError(ctx, form.State, form.RedirectURI)
+ return
+ }
+ redirect, err := code.GenerateRedirectURI(form.State)
+ if err != nil {
+ handleServerError(ctx, form.State, form.RedirectURI)
+ return
+ }
+ // Update nonce to reflect the new session
+ if len(form.Nonce) > 0 {
+ err := grant.SetNonce(form.Nonce)
+ if err != nil {
+ log.Error("Unable to update nonce: %v", err)
+ }
+ }
+ ctx.Redirect(redirect.String(), 302)
+ return
+ }
+
+ // show authorize page to grant access
+ ctx.Data["Application"] = app
+ ctx.Data["RedirectURI"] = form.RedirectURI
+ ctx.Data["State"] = form.State
+ ctx.Data["Scope"] = form.Scope
+ ctx.Data["Nonce"] = form.Nonce
+ ctx.Data["ApplicationUserLink"] = "<a href=\"" + html.EscapeString(setting.AppURL) + html.EscapeString(url.PathEscape(app.User.LowerName)) + "\">@" + html.EscapeString(app.User.Name) + "</a>"
+ ctx.Data["ApplicationRedirectDomainHTML"] = "<strong>" + html.EscapeString(form.RedirectURI) + "</strong>"
+ // TODO document SESSION <=> FORM
+ err = ctx.Session.Set("client_id", app.ClientID)
+ if err != nil {
+ handleServerError(ctx, form.State, form.RedirectURI)
+ log.Error(err.Error())
+ return
+ }
+ err = ctx.Session.Set("redirect_uri", form.RedirectURI)
+ if err != nil {
+ handleServerError(ctx, form.State, form.RedirectURI)
+ log.Error(err.Error())
+ return
+ }
+ err = ctx.Session.Set("state", form.State)
+ if err != nil {
+ handleServerError(ctx, form.State, form.RedirectURI)
+ log.Error(err.Error())
+ return
+ }
+ // Here we're just going to try to release the session early
+ if err := ctx.Session.Release(); err != nil {
+ // we'll tolerate errors here as they *should* get saved elsewhere
+ log.Error("Unable to save changes to the session: %v", err)
+ }
+ ctx.HTML(http.StatusOK, tplGrantAccess)
+}
+
+// GrantApplicationOAuth manages the post request submitted when a user grants access to an application
+func GrantApplicationOAuth(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.GrantApplicationForm)
+ if ctx.Session.Get("client_id") != form.ClientID || ctx.Session.Get("state") != form.State ||
+ ctx.Session.Get("redirect_uri") != form.RedirectURI {
+ ctx.Error(http.StatusBadRequest)
+ return
+ }
+ app, err := models.GetOAuth2ApplicationByClientID(form.ClientID)
+ if err != nil {
+ ctx.ServerError("GetOAuth2ApplicationByClientID", err)
+ return
+ }
+ grant, err := app.CreateGrant(ctx.User.ID, form.Scope)
+ if err != nil {
+ handleAuthorizeError(ctx, AuthorizeError{
+ State: form.State,
+ ErrorDescription: "cannot create grant for user",
+ ErrorCode: ErrorCodeServerError,
+ }, form.RedirectURI)
+ return
+ }
+ if len(form.Nonce) > 0 {
+ err := grant.SetNonce(form.Nonce)
+ if err != nil {
+ log.Error("Unable to update nonce: %v", err)
+ }
+ }
+
+ var codeChallenge, codeChallengeMethod string
+ codeChallenge, _ = ctx.Session.Get("CodeChallenge").(string)
+ codeChallengeMethod, _ = ctx.Session.Get("CodeChallengeMethod").(string)
+
+ code, err := grant.GenerateNewAuthorizationCode(form.RedirectURI, codeChallenge, codeChallengeMethod)
+ if err != nil {
+ handleServerError(ctx, form.State, form.RedirectURI)
+ return
+ }
+ redirect, err := code.GenerateRedirectURI(form.State)
+ if err != nil {
+ handleServerError(ctx, form.State, form.RedirectURI)
+ return
+ }
+ ctx.Redirect(redirect.String(), 302)
+}
+
+// OIDCWellKnown generates JSON so OIDC clients know Gitea's capabilities
+func OIDCWellKnown(ctx *context.Context) {
+ t := ctx.Render.TemplateLookup("user/auth/oidc_wellknown")
+ ctx.Resp.Header().Set("Content-Type", "application/json")
+ if err := t.Execute(ctx.Resp, ctx.Data); err != nil {
+ log.Error("%v", err)
+ ctx.Error(http.StatusInternalServerError)
+ }
+}
+
+// AccessTokenOAuth manages all access token requests by the client
+func AccessTokenOAuth(ctx *context.Context) {
+ form := *web.GetForm(ctx).(*forms.AccessTokenForm)
+ if form.ClientID == "" {
+ authHeader := ctx.Req.Header.Get("Authorization")
+ authContent := strings.SplitN(authHeader, " ", 2)
+ if len(authContent) == 2 && authContent[0] == "Basic" {
+ payload, err := base64.StdEncoding.DecodeString(authContent[1])
+ if err != nil {
+ handleAccessTokenError(ctx, AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidRequest,
+ ErrorDescription: "cannot parse basic auth header",
+ })
+ return
+ }
+ pair := strings.SplitN(string(payload), ":", 2)
+ if len(pair) != 2 {
+ handleAccessTokenError(ctx, AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidRequest,
+ ErrorDescription: "cannot parse basic auth header",
+ })
+ return
+ }
+ form.ClientID = pair[0]
+ form.ClientSecret = pair[1]
+ }
+ }
+ switch form.GrantType {
+ case "refresh_token":
+ handleRefreshToken(ctx, form)
+ return
+ case "authorization_code":
+ handleAuthorizationCode(ctx, form)
+ return
+ default:
+ handleAccessTokenError(ctx, AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeUnsupportedGrantType,
+ ErrorDescription: "Only refresh_token or authorization_code grant type is supported",
+ })
+ }
+}
+
+func handleRefreshToken(ctx *context.Context, form forms.AccessTokenForm) {
+ token, err := models.ParseOAuth2Token(form.RefreshToken)
+ if err != nil {
+ handleAccessTokenError(ctx, AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeUnauthorizedClient,
+ ErrorDescription: "client is not authorized",
+ })
+ return
+ }
+ // get grant before increasing counter
+ grant, err := models.GetOAuth2GrantByID(token.GrantID)
+ if err != nil || grant == nil {
+ handleAccessTokenError(ctx, AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidGrant,
+ ErrorDescription: "grant does not exist",
+ })
+ return
+ }
+
+ // check if token got already used
+ if setting.OAuth2.InvalidateRefreshTokens && (grant.Counter != token.Counter || token.Counter == 0) {
+ handleAccessTokenError(ctx, AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeUnauthorizedClient,
+ ErrorDescription: "token was already used",
+ })
+ log.Warn("A client tried to use a refresh token for grant_id = %d was used twice!", grant.ID)
+ return
+ }
+ accessToken, tokenErr := newAccessTokenResponse(grant, form.ClientSecret)
+ if tokenErr != nil {
+ handleAccessTokenError(ctx, *tokenErr)
+ return
+ }
+ ctx.JSON(http.StatusOK, accessToken)
+}
+
+func handleAuthorizationCode(ctx *context.Context, form forms.AccessTokenForm) {
+ app, err := models.GetOAuth2ApplicationByClientID(form.ClientID)
+ if err != nil {
+ handleAccessTokenError(ctx, AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidClient,
+ ErrorDescription: fmt.Sprintf("cannot load client with client id: '%s'", form.ClientID),
+ })
+ return
+ }
+ if !app.ValidateClientSecret([]byte(form.ClientSecret)) {
+ handleAccessTokenError(ctx, AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeUnauthorizedClient,
+ ErrorDescription: "client is not authorized",
+ })
+ return
+ }
+ if form.RedirectURI != "" && !app.ContainsRedirectURI(form.RedirectURI) {
+ handleAccessTokenError(ctx, AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeUnauthorizedClient,
+ ErrorDescription: "client is not authorized",
+ })
+ return
+ }
+ authorizationCode, err := models.GetOAuth2AuthorizationByCode(form.Code)
+ if err != nil || authorizationCode == nil {
+ handleAccessTokenError(ctx, AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeUnauthorizedClient,
+ ErrorDescription: "client is not authorized",
+ })
+ return
+ }
+ // check if code verifier authorizes the client, PKCE support
+ if !authorizationCode.ValidateCodeChallenge(form.CodeVerifier) {
+ handleAccessTokenError(ctx, AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeUnauthorizedClient,
+ ErrorDescription: "client is not authorized",
+ })
+ return
+ }
+ // check if granted for this application
+ if authorizationCode.Grant.ApplicationID != app.ID {
+ handleAccessTokenError(ctx, AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidGrant,
+ ErrorDescription: "invalid grant",
+ })
+ return
+ }
+ // remove token from database to deny duplicate usage
+ if err := authorizationCode.Invalidate(); err != nil {
+ handleAccessTokenError(ctx, AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidRequest,
+ ErrorDescription: "cannot proceed your request",
+ })
+ }
+ resp, tokenErr := newAccessTokenResponse(authorizationCode.Grant, form.ClientSecret)
+ if tokenErr != nil {
+ handleAccessTokenError(ctx, *tokenErr)
+ return
+ }
+ // send successful response
+ ctx.JSON(http.StatusOK, resp)
+}
+
+func handleAccessTokenError(ctx *context.Context, acErr AccessTokenError) {
+ ctx.JSON(http.StatusBadRequest, acErr)
+}
+
+func handleServerError(ctx *context.Context, state string, redirectURI string) {
+ handleAuthorizeError(ctx, AuthorizeError{
+ ErrorCode: ErrorCodeServerError,
+ ErrorDescription: "A server error occurred",
+ State: state,
+ }, redirectURI)
+}
+
+func handleAuthorizeError(ctx *context.Context, authErr AuthorizeError, redirectURI string) {
+ if redirectURI == "" {
+ log.Warn("Authorization failed: %v", authErr.ErrorDescription)
+ ctx.Data["Error"] = authErr
+ ctx.HTML(400, tplGrantError)
+ return
+ }
+ redirect, err := url.Parse(redirectURI)
+ if err != nil {
+ ctx.ServerError("url.Parse", err)
+ return
+ }
+ q := redirect.Query()
+ q.Set("error", string(authErr.ErrorCode))
+ q.Set("error_description", authErr.ErrorDescription)
+ q.Set("state", authErr.State)
+ redirect.RawQuery = q.Encode()
+ ctx.Redirect(redirect.String(), 302)
+}
+
+func handleBearerTokenError(ctx *context.Context, beErr BearerTokenError) {
+ ctx.Resp.Header().Set("WWW-Authenticate", fmt.Sprintf("Bearer realm=\"\", error=\"%s\", error_description=\"%s\"", beErr.ErrorCode, beErr.ErrorDescription))
+ switch beErr.ErrorCode {
+ case BearerTokenErrorCodeInvalidRequest:
+ ctx.JSON(http.StatusBadRequest, beErr)
+ case BearerTokenErrorCodeInvalidToken:
+ ctx.JSON(http.StatusUnauthorized, beErr)
+ case BearerTokenErrorCodeInsufficientScope:
+ ctx.JSON(http.StatusForbidden, beErr)
+ default:
+ log.Error("Invalid BearerTokenErrorCode: %v", beErr.ErrorCode)
+ ctx.ServerError("Unhandled BearerTokenError", fmt.Errorf("BearerTokenError: error=\"%v\", error_description=\"%v\"", beErr.ErrorCode, beErr.ErrorDescription))
+ }
+}
diff --git a/routers/web/user/profile.go b/routers/web/user/profile.go
new file mode 100644
index 0000000000..e66820e131
--- /dev/null
+++ b/routers/web/user/profile.go
@@ -0,0 +1,329 @@
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// Copyright 2019 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
+
+import (
+ "fmt"
+ "net/http"
+ "path"
+ "strings"
+
+ "code.gitea.io/gitea/models"
+ "code.gitea.io/gitea/modules/context"
+ "code.gitea.io/gitea/modules/markup"
+ "code.gitea.io/gitea/modules/markup/markdown"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/routers/web/org"
+)
+
+// GetUserByName get user by name
+func GetUserByName(ctx *context.Context, name string) *models.User {
+ user, err := models.GetUserByName(name)
+ if err != nil {
+ if models.IsErrUserNotExist(err) {
+ if redirectUserID, err := models.LookupUserRedirect(name); err == nil {
+ context.RedirectToUser(ctx, name, redirectUserID)
+ } else {
+ ctx.NotFound("GetUserByName", err)
+ }
+ } else {
+ ctx.ServerError("GetUserByName", err)
+ }
+ return nil
+ }
+ return user
+}
+
+// GetUserByParams returns user whose name is presented in URL paramenter.
+func GetUserByParams(ctx *context.Context) *models.User {
+ return GetUserByName(ctx, ctx.Params(":username"))
+}
+
+// Profile render user's profile page
+func Profile(ctx *context.Context) {
+ uname := ctx.Params(":username")
+
+ // Special handle for FireFox requests favicon.ico.
+ if uname == "favicon.ico" {
+ ctx.ServeFile(path.Join(setting.StaticRootPath, "public/img/favicon.png"))
+ return
+ }
+
+ if strings.HasSuffix(uname, ".png") {
+ ctx.Error(http.StatusNotFound)
+ return
+ }
+
+ isShowKeys := false
+ if strings.HasSuffix(uname, ".keys") {
+ isShowKeys = true
+ uname = strings.TrimSuffix(uname, ".keys")
+ }
+
+ isShowGPG := false
+ if strings.HasSuffix(uname, ".gpg") {
+ isShowGPG = true
+ uname = strings.TrimSuffix(uname, ".gpg")
+ }
+
+ ctxUser := GetUserByName(ctx, uname)
+ if ctx.Written() {
+ return
+ }
+
+ // Show SSH keys.
+ if isShowKeys {
+ ShowSSHKeys(ctx, ctxUser.ID)
+ return
+ }
+
+ // Show GPG keys.
+ if isShowGPG {
+ ShowGPGKeys(ctx, ctxUser.ID)
+ return
+ }
+
+ if ctxUser.IsOrganization() {
+ org.Home(ctx)
+ return
+ }
+
+ // Show OpenID URIs
+ openIDs, err := models.GetUserOpenIDs(ctxUser.ID)
+ if err != nil {
+ ctx.ServerError("GetUserOpenIDs", err)
+ return
+ }
+
+ ctx.Data["Title"] = ctxUser.DisplayName()
+ ctx.Data["PageIsUserProfile"] = true
+ ctx.Data["Owner"] = ctxUser
+ ctx.Data["OpenIDs"] = openIDs
+
+ if setting.Service.EnableUserHeatmap {
+ data, err := models.GetUserHeatmapDataByUser(ctxUser, ctx.User)
+ if err != nil {
+ ctx.ServerError("GetUserHeatmapDataByUser", err)
+ return
+ }
+ ctx.Data["HeatmapData"] = data
+ }
+
+ if len(ctxUser.Description) != 0 {
+ content, err := markdown.RenderString(&markup.RenderContext{
+ URLPrefix: ctx.Repo.RepoLink,
+ Metas: map[string]string{"mode": "document"},
+ }, ctxUser.Description)
+ if err != nil {
+ ctx.ServerError("RenderString", err)
+ return
+ }
+ ctx.Data["RenderedDescription"] = content
+ }
+
+ showPrivate := ctx.IsSigned && (ctx.User.IsAdmin || ctx.User.ID == ctxUser.ID)
+
+ orgs, err := models.GetOrgsByUserID(ctxUser.ID, showPrivate)
+ if err != nil {
+ ctx.ServerError("GetOrgsByUserIDDesc", err)
+ return
+ }
+
+ ctx.Data["Orgs"] = orgs
+ ctx.Data["HasOrgsVisible"] = models.HasOrgsVisible(orgs, ctx.User)
+
+ tab := ctx.Query("tab")
+ ctx.Data["TabName"] = tab
+
+ page := ctx.QueryInt("page")
+ if page <= 0 {
+ page = 1
+ }
+
+ topicOnly := ctx.QueryBool("topic")
+
+ var (
+ repos []*models.Repository
+ count int64
+ total int
+ orderBy models.SearchOrderBy
+ )
+
+ ctx.Data["SortType"] = ctx.Query("sort")
+ switch ctx.Query("sort") {
+ case "newest":
+ orderBy = models.SearchOrderByNewest
+ case "oldest":
+ orderBy = models.SearchOrderByOldest
+ case "recentupdate":
+ orderBy = models.SearchOrderByRecentUpdated
+ case "leastupdate":
+ orderBy = models.SearchOrderByLeastUpdated
+ case "reversealphabetically":
+ orderBy = models.SearchOrderByAlphabeticallyReverse
+ case "alphabetically":
+ orderBy = models.SearchOrderByAlphabetically
+ case "moststars":
+ orderBy = models.SearchOrderByStarsReverse
+ case "feweststars":
+ orderBy = models.SearchOrderByStars
+ case "mostforks":
+ orderBy = models.SearchOrderByForksReverse
+ case "fewestforks":
+ orderBy = models.SearchOrderByForks
+ default:
+ ctx.Data["SortType"] = "recentupdate"
+ orderBy = models.SearchOrderByRecentUpdated
+ }
+
+ keyword := strings.Trim(ctx.Query("q"), " ")
+ ctx.Data["Keyword"] = keyword
+ switch tab {
+ case "followers":
+ items, err := ctxUser.GetFollowers(models.ListOptions{
+ PageSize: setting.UI.User.RepoPagingNum,
+ Page: page,
+ })
+ if err != nil {
+ ctx.ServerError("GetFollowers", err)
+ return
+ }
+ ctx.Data["Cards"] = items
+
+ total = ctxUser.NumFollowers
+ case "following":
+ items, err := ctxUser.GetFollowing(models.ListOptions{
+ PageSize: setting.UI.User.RepoPagingNum,
+ Page: page,
+ })
+ if err != nil {
+ ctx.ServerError("GetFollowing", err)
+ return
+ }
+ ctx.Data["Cards"] = items
+
+ total = ctxUser.NumFollowing
+ case "activity":
+ retrieveFeeds(ctx, models.GetFeedsOptions{RequestedUser: ctxUser,
+ Actor: ctx.User,
+ IncludePrivate: showPrivate,
+ OnlyPerformedBy: true,
+ IncludeDeleted: false,
+ Date: ctx.Query("date"),
+ })
+ if ctx.Written() {
+ return
+ }
+ case "stars":
+ ctx.Data["PageIsProfileStarList"] = true
+ repos, count, err = models.SearchRepository(&models.SearchRepoOptions{
+ ListOptions: models.ListOptions{
+ PageSize: setting.UI.User.RepoPagingNum,
+ Page: page,
+ },
+ Actor: ctx.User,
+ Keyword: keyword,
+ OrderBy: orderBy,
+ Private: ctx.IsSigned,
+ StarredByID: ctxUser.ID,
+ Collaborate: util.OptionalBoolFalse,
+ TopicOnly: topicOnly,
+ IncludeDescription: setting.UI.SearchRepoDescription,
+ })
+ if err != nil {
+ ctx.ServerError("SearchRepository", err)
+ return
+ }
+
+ total = int(count)
+ case "projects":
+ ctx.Data["OpenProjects"], _, err = models.GetProjects(models.ProjectSearchOptions{
+ Page: -1,
+ IsClosed: util.OptionalBoolFalse,
+ Type: models.ProjectTypeIndividual,
+ })
+ if err != nil {
+ ctx.ServerError("GetProjects", err)
+ return
+ }
+ case "watching":
+ repos, count, err = models.SearchRepository(&models.SearchRepoOptions{
+ ListOptions: models.ListOptions{
+ PageSize: setting.UI.User.RepoPagingNum,
+ Page: page,
+ },
+ Actor: ctx.User,
+ Keyword: keyword,
+ OrderBy: orderBy,
+ Private: ctx.IsSigned,
+ WatchedByID: ctxUser.ID,
+ Collaborate: util.OptionalBoolFalse,
+ TopicOnly: topicOnly,
+ IncludeDescription: setting.UI.SearchRepoDescription,
+ })
+ if err != nil {
+ ctx.ServerError("SearchRepository", err)
+ return
+ }
+
+ total = int(count)
+ default:
+ repos, count, err = models.SearchRepository(&models.SearchRepoOptions{
+ ListOptions: models.ListOptions{
+ PageSize: setting.UI.User.RepoPagingNum,
+ Page: page,
+ },
+ Actor: ctx.User,
+ Keyword: keyword,
+ OwnerID: ctxUser.ID,
+ OrderBy: orderBy,
+ Private: ctx.IsSigned,
+ Collaborate: util.OptionalBoolFalse,
+ TopicOnly: topicOnly,
+ IncludeDescription: setting.UI.SearchRepoDescription,
+ })
+ if err != nil {
+ ctx.ServerError("SearchRepository", err)
+ return
+ }
+
+ total = int(count)
+ }
+ ctx.Data["Repos"] = repos
+ ctx.Data["Total"] = total
+
+ pager := context.NewPagination(total, setting.UI.User.RepoPagingNum, page, 5)
+ pager.SetDefaultParams(ctx)
+ ctx.Data["Page"] = pager
+
+ ctx.Data["ShowUserEmail"] = len(ctxUser.Email) > 0 && ctx.IsSigned && (!ctxUser.KeepEmailPrivate || ctxUser.ID == ctx.User.ID)
+
+ ctx.HTML(http.StatusOK, tplProfile)
+}
+
+// Action response for follow/unfollow user request
+func Action(ctx *context.Context) {
+ u := GetUserByParams(ctx)
+ if ctx.Written() {
+ return
+ }
+
+ var err error
+ switch ctx.Params(":action") {
+ case "follow":
+ err = models.FollowUser(ctx.User.ID, u.ID)
+ case "unfollow":
+ err = models.UnfollowUser(ctx.User.ID, u.ID)
+ }
+
+ if err != nil {
+ ctx.ServerError(fmt.Sprintf("Action (%s)", ctx.Params(":action")), err)
+ return
+ }
+
+ ctx.RedirectToFirst(ctx.Query("redirect_to"), u.HomeLink())
+}
diff --git a/routers/web/user/setting/account.go b/routers/web/user/setting/account.go
new file mode 100644
index 0000000000..48ab37d936
--- /dev/null
+++ b/routers/web/user/setting/account.go
@@ -0,0 +1,313 @@
+// 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"
+ "net/http"
+ "time"
+
+ "code.gitea.io/gitea/models"
+ "code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/context"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/password"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/timeutil"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/services/forms"
+ "code.gitea.io/gitea/services/mailer"
+)
+
+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
+
+ loadAccountData(ctx)
+
+ ctx.HTML(http.StatusOK, tplSettingsAccount)
+}
+
+// AccountPost response for change user's password
+func AccountPost(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.ChangePasswordForm)
+ ctx.Data["Title"] = ctx.Tr("settings")
+ ctx.Data["PageIsSettingsAccount"] = true
+
+ if ctx.HasError() {
+ loadAccountData(ctx)
+
+ ctx.HTML(http.StatusOK, 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 if !password.IsComplexEnough(form.Password) {
+ ctx.Flash.Error(password.BuildComplexityError(ctx))
+ } else if pwned, err := password.IsPwned(ctx, form.Password); pwned || err != nil {
+ errMsg := ctx.Tr("auth.password_pwned")
+ if err != nil {
+ log.Error(err.Error())
+ errMsg = ctx.Tr("auth.password_pwned_err")
+ }
+ ctx.Flash.Error(errMsg)
+ } else {
+ var err error
+ if err = ctx.User.SetPassword(form.Password); err != nil {
+ ctx.ServerError("UpdateUser", err)
+ return
+ }
+ if err := models.UpdateUserCols(ctx.User, "salt", "passwd_hash_algo", "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 := web.GetForm(ctx).(*forms.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
+ }
+ // Send activation Email
+ if ctx.Query("_method") == "SENDACTIVATION" {
+ var address string
+ if ctx.Cache.IsExist("MailResendLimit_" + ctx.User.LowerName) {
+ log.Error("Send activation: activation still pending")
+ ctx.Redirect(setting.AppSubURL + "/user/settings/account")
+ return
+ }
+ if ctx.Query("id") == "PRIMARY" {
+ if ctx.User.IsActive {
+ log.Error("Send activation: email not set for activation")
+ ctx.Redirect(setting.AppSubURL + "/user/settings/account")
+ return
+ }
+ mailer.SendActivateAccountMail(ctx.Locale, ctx.User)
+ address = ctx.User.Email
+ } else {
+ id := ctx.QueryInt64("id")
+ email, err := models.GetEmailAddressByID(ctx.User.ID, id)
+ if err != nil {
+ log.Error("GetEmailAddressByID(%d,%d) error: %v", ctx.User.ID, id, err)
+ ctx.Redirect(setting.AppSubURL + "/user/settings/account")
+ return
+ }
+ if email == nil {
+ log.Error("Send activation: EmailAddress not found; user:%d, id: %d", ctx.User.ID, id)
+ ctx.Redirect(setting.AppSubURL + "/user/settings/account")
+ return
+ }
+ if email.IsActivated {
+ log.Error("Send activation: email not set for activation")
+ ctx.Redirect(setting.AppSubURL + "/user/settings/account")
+ return
+ }
+ mailer.SendActivateEmailMail(ctx.User, email)
+ address = email.Email
+ }
+
+ if err := ctx.Cache.Put("MailResendLimit_"+ctx.User.LowerName, ctx.User.LowerName, 180); err != nil {
+ log.Error("Set cache(MailResendLimit) fail: %v", err)
+ }
+ ctx.Flash.Info(ctx.Tr("settings.add_email_confirmation_sent", address, timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, ctx.Locale.Language())))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/account")
+ return
+ }
+ // Set Email Notification Preference
+ if ctx.Query("_method") == "NOTIFICATION" {
+ preference := ctx.Query("preference")
+ if !(preference == models.EmailNotificationsEnabled ||
+ preference == models.EmailNotificationsOnMention ||
+ preference == models.EmailNotificationsDisabled) {
+ log.Error("Email notifications preference change returned unrecognized option %s: %s", preference, ctx.User.Name)
+ ctx.ServerError("SetEmailPreference", errors.New("option unrecognized"))
+ return
+ }
+ if err := ctx.User.SetEmailNotifications(preference); err != nil {
+ log.Error("Set Email Notifications failed: %v", err)
+ ctx.ServerError("SetEmailNotifications", err)
+ return
+ }
+ log.Trace("Email notifications preference made %s: %s", preference, ctx.User.Name)
+ ctx.Flash.Success(ctx.Tr("settings.email_preference_set_success"))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/account")
+ return
+ }
+
+ if ctx.HasError() {
+ loadAccountData(ctx)
+
+ ctx.HTML(http.StatusOK, 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) {
+ loadAccountData(ctx)
+
+ ctx.RenderWithErr(ctx.Tr("form.email_been_used"), tplSettingsAccount, &form)
+ return
+ } else if models.IsErrEmailInvalid(err) {
+ loadAccountData(ctx)
+
+ ctx.RenderWithErr(ctx.Tr("form.email_invalid"), tplSettingsAccount, &form)
+ return
+ }
+ ctx.ServerError("AddEmailAddress", err)
+ return
+ }
+
+ // Send confirmation email
+ if setting.Service.RegisterEmailConfirm {
+ mailer.SendActivateEmailMail(ctx.User, email)
+ if err := ctx.Cache.Put("MailResendLimit_"+ctx.User.LowerName, ctx.User.LowerName, 180); err != nil {
+ log.Error("Set cache(MailResendLimit) fail: %v", err)
+ }
+ ctx.Flash.Info(ctx.Tr("settings.add_email_confirmation_sent", email.Email, timeutil.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(http.StatusOK, 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) {
+ loadAccountData(ctx)
+
+ 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 + "/")
+ }
+}
+
+// UpdateUIThemePost is used to update users' specific theme
+func UpdateUIThemePost(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.UpdateThemeForm)
+ ctx.Data["Title"] = ctx.Tr("settings")
+ ctx.Data["PageIsSettingsAccount"] = true
+
+ if ctx.HasError() {
+ ctx.Redirect(setting.AppSubURL + "/user/settings/account")
+ return
+ }
+
+ if !form.IsThemeExists() {
+ ctx.Flash.Error(ctx.Tr("settings.theme_update_error"))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/account")
+ return
+ }
+
+ if err := ctx.User.UpdateTheme(form.Theme); err != nil {
+ ctx.Flash.Error(ctx.Tr("settings.theme_update_error"))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/account")
+ return
+ }
+
+ log.Trace("Update user theme: %s", ctx.User.Name)
+ ctx.Flash.Success(ctx.Tr("settings.theme_update_success"))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/account")
+}
+
+func loadAccountData(ctx *context.Context) {
+ emlist, err := models.GetEmailAddresses(ctx.User.ID)
+ if err != nil {
+ ctx.ServerError("GetEmailAddresses", err)
+ return
+ }
+ type UserEmail struct {
+ models.EmailAddress
+ CanBePrimary bool
+ }
+ pendingActivation := ctx.Cache.IsExist("MailResendLimit_" + ctx.User.LowerName)
+ emails := make([]*UserEmail, len(emlist))
+ for i, em := range emlist {
+ var email UserEmail
+ email.EmailAddress = *em
+ email.CanBePrimary = em.IsActivated
+ emails[i] = &email
+ }
+ ctx.Data["Emails"] = emails
+ ctx.Data["EmailNotificationsPreference"] = ctx.User.EmailNotifications()
+ ctx.Data["ActivationsPending"] = pendingActivation
+ ctx.Data["CanAddEmails"] = !pendingActivation || !setting.Service.RegisterEmailConfirm
+
+ if setting.Service.UserDeleteWithCommentsMaxTime != 0 {
+ ctx.Data["UserDeleteWithCommentsMaxTime"] = setting.Service.UserDeleteWithCommentsMaxTime.String()
+ ctx.Data["UserDeleteWithComments"] = ctx.User.CreatedUnix.AsTime().Add(setting.Service.UserDeleteWithCommentsMaxTime).After(time.Now())
+ }
+}
diff --git a/routers/web/user/setting/account_test.go b/routers/web/user/setting/account_test.go
new file mode 100644
index 0000000000..25b68da762
--- /dev/null
+++ b/routers/web/user/setting/account_test.go
@@ -0,0 +1,99 @@
+// 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 (
+ "net/http"
+ "testing"
+
+ "code.gitea.io/gitea/models"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/test"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/services/forms"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestChangePassword(t *testing.T) {
+ oldPassword := "password"
+ setting.MinPasswordLength = 6
+ var pcALL = []string{"lower", "upper", "digit", "spec"}
+ var pcLUN = []string{"lower", "upper", "digit"}
+ var pcLU = []string{"lower", "upper"}
+
+ for _, req := range []struct {
+ OldPassword string
+ NewPassword string
+ Retype string
+ Message string
+ PasswordComplexity []string
+ }{
+ {
+ OldPassword: oldPassword,
+ NewPassword: "Qwerty123456-",
+ Retype: "Qwerty123456-",
+ Message: "",
+ PasswordComplexity: pcALL,
+ },
+ {
+ OldPassword: oldPassword,
+ NewPassword: "12345",
+ Retype: "12345",
+ Message: "auth.password_too_short",
+ PasswordComplexity: pcALL,
+ },
+ {
+ OldPassword: "12334",
+ NewPassword: "123456",
+ Retype: "123456",
+ Message: "settings.password_incorrect",
+ PasswordComplexity: pcALL,
+ },
+ {
+ OldPassword: oldPassword,
+ NewPassword: "123456",
+ Retype: "12345",
+ Message: "form.password_not_match",
+ PasswordComplexity: pcALL,
+ },
+ {
+ OldPassword: oldPassword,
+ NewPassword: "Qwerty",
+ Retype: "Qwerty",
+ Message: "form.password_complexity",
+ PasswordComplexity: pcALL,
+ },
+ {
+ OldPassword: oldPassword,
+ NewPassword: "Qwerty",
+ Retype: "Qwerty",
+ Message: "form.password_complexity",
+ PasswordComplexity: pcLUN,
+ },
+ {
+ OldPassword: oldPassword,
+ NewPassword: "QWERTY",
+ Retype: "QWERTY",
+ Message: "form.password_complexity",
+ PasswordComplexity: pcLU,
+ },
+ } {
+ models.PrepareTestEnv(t)
+ ctx := test.MockContext(t, "user/settings/security")
+ test.LoadUser(t, ctx, 2)
+ test.LoadRepo(t, ctx, 1)
+
+ web.SetForm(ctx, &forms.ChangePasswordForm{
+ OldPassword: req.OldPassword,
+ Password: req.NewPassword,
+ Retype: req.Retype,
+ })
+ AccountPost(ctx)
+
+ assert.Contains(t, ctx.Flash.ErrorMsg, req.Message)
+ assert.EqualValues(t, http.StatusFound, ctx.Resp.Status())
+ }
+}
diff --git a/routers/web/user/setting/adopt.go b/routers/web/user/setting/adopt.go
new file mode 100644
index 0000000000..b2d918784f
--- /dev/null
+++ b/routers/web/user/setting/adopt.go
@@ -0,0 +1,64 @@
+// Copyright 2020 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"
+
+ "code.gitea.io/gitea/models"
+ "code.gitea.io/gitea/modules/context"
+ "code.gitea.io/gitea/modules/repository"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/util"
+)
+
+// AdoptOrDeleteRepository adopts or deletes a repository
+func AdoptOrDeleteRepository(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("settings")
+ ctx.Data["PageIsSettingsRepos"] = true
+ allowAdopt := ctx.IsUserSiteAdmin() || setting.Repository.AllowAdoptionOfUnadoptedRepositories
+ ctx.Data["allowAdopt"] = allowAdopt
+ allowDelete := ctx.IsUserSiteAdmin() || setting.Repository.AllowDeleteOfUnadoptedRepositories
+ ctx.Data["allowDelete"] = allowDelete
+
+ dir := ctx.Query("id")
+ action := ctx.Query("action")
+
+ ctxUser := ctx.User
+ root := filepath.Join(models.UserPath(ctxUser.LowerName))
+
+ // check not a repo
+ has, err := models.IsRepositoryExist(ctxUser, dir)
+ if err != nil {
+ ctx.ServerError("IsRepositoryExist", err)
+ return
+ }
+
+ isDir, err := util.IsDir(filepath.Join(root, dir+".git"))
+ if err != nil {
+ ctx.ServerError("IsDir", err)
+ return
+ }
+ if has || !isDir {
+ // Fallthrough to failure mode
+ } else if action == "adopt" && allowAdopt {
+ if _, err := repository.AdoptRepository(ctxUser, ctxUser, models.CreateRepoOptions{
+ Name: dir,
+ IsPrivate: true,
+ }); err != nil {
+ ctx.ServerError("repository.AdoptRepository", err)
+ return
+ }
+ ctx.Flash.Success(ctx.Tr("repo.adopt_preexisting_success", dir))
+ } else if action == "delete" && allowDelete {
+ if err := repository.DeleteUnadoptedRepository(ctxUser, ctxUser, dir); err != nil {
+ ctx.ServerError("repository.AdoptRepository", err)
+ return
+ }
+ ctx.Flash.Success(ctx.Tr("repo.delete_preexisting_success", dir))
+ }
+
+ ctx.Redirect(setting.AppSubURL + "/user/settings/repos")
+}
diff --git a/routers/web/user/setting/applications.go b/routers/web/user/setting/applications.go
new file mode 100644
index 0000000000..4161efdea4
--- /dev/null
+++ b/routers/web/user/setting/applications.go
@@ -0,0 +1,106 @@
+// 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 (
+ "net/http"
+
+ "code.gitea.io/gitea/models"
+ "code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/context"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/services/forms"
+)
+
+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
+
+ loadApplicationsData(ctx)
+
+ ctx.HTML(http.StatusOK, tplSettingsApplications)
+}
+
+// ApplicationsPost response for add user's access token
+func ApplicationsPost(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.NewAccessTokenForm)
+ ctx.Data["Title"] = ctx.Tr("settings")
+ ctx.Data["PageIsSettingsApplications"] = true
+
+ if ctx.HasError() {
+ loadApplicationsData(ctx)
+
+ ctx.HTML(http.StatusOK, tplSettingsApplications)
+ return
+ }
+
+ t := &models.AccessToken{
+ UID: ctx.User.ID,
+ Name: form.Name,
+ }
+
+ exist, err := models.AccessTokenByNameExists(t)
+ if err != nil {
+ ctx.ServerError("AccessTokenByNameExists", err)
+ return
+ }
+ if exist {
+ ctx.Flash.Error(ctx.Tr("settings.generate_token_name_duplicate", t.Name))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/applications")
+ return
+ }
+
+ 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.Token)
+
+ 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(http.StatusOK, map[string]interface{}{
+ "redirect": setting.AppSubURL + "/user/settings/applications",
+ })
+}
+
+func loadApplicationsData(ctx *context.Context) {
+ tokens, err := models.ListAccessTokens(models.ListAccessTokensOptions{UserID: ctx.User.ID})
+ if err != nil {
+ ctx.ServerError("ListAccessTokens", err)
+ return
+ }
+ ctx.Data["Tokens"] = tokens
+ ctx.Data["EnableOAuth2"] = setting.OAuth2.Enable
+ if setting.OAuth2.Enable {
+ ctx.Data["Applications"], err = models.GetOAuth2ApplicationsByUserID(ctx.User.ID)
+ if err != nil {
+ ctx.ServerError("GetOAuth2ApplicationsByUserID", err)
+ return
+ }
+ ctx.Data["Grants"], err = models.GetOAuth2GrantsByUserID(ctx.User.ID)
+ if err != nil {
+ ctx.ServerError("GetOAuth2GrantsByUserID", err)
+ return
+ }
+ }
+}
diff --git a/routers/web/user/setting/keys.go b/routers/web/user/setting/keys.go
new file mode 100644
index 0000000000..e56a33afcb
--- /dev/null
+++ b/routers/web/user/setting/keys.go
@@ -0,0 +1,226 @@
+// 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 (
+ "net/http"
+
+ "code.gitea.io/gitea/models"
+ "code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/context"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/services/forms"
+)
+
+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
+ ctx.Data["BuiltinSSH"] = setting.SSH.StartBuiltinServer
+ ctx.Data["AllowPrincipals"] = setting.SSH.AuthorizedPrincipalsEnabled
+
+ loadKeysData(ctx)
+
+ ctx.HTML(http.StatusOK, tplSettingsKeys)
+}
+
+// KeysPost response for change user's SSH/GPG keys
+func KeysPost(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.AddKeyForm)
+ ctx.Data["Title"] = ctx.Tr("settings")
+ ctx.Data["PageIsSettingsKeys"] = true
+ ctx.Data["DisableSSH"] = setting.SSH.Disabled
+ ctx.Data["BuiltinSSH"] = setting.SSH.StartBuiltinServer
+ ctx.Data["AllowPrincipals"] = setting.SSH.AuthorizedPrincipalsEnabled
+
+ if ctx.HasError() {
+ loadKeysData(ctx)
+
+ ctx.HTML(http.StatusOK, tplSettingsKeys)
+ return
+ }
+ switch form.Type {
+ case "principal":
+ content, err := models.CheckPrincipalKeyString(ctx.User, form.Content)
+ if err != nil {
+ if models.IsErrSSHDisabled(err) {
+ ctx.Flash.Info(ctx.Tr("settings.ssh_disabled"))
+ } else {
+ ctx.Flash.Error(ctx.Tr("form.invalid_ssh_principal", err.Error()))
+ }
+ ctx.Redirect(setting.AppSubURL + "/user/settings/keys")
+ return
+ }
+ if _, err = models.AddPrincipalKey(ctx.User.ID, content, 0); err != nil {
+ ctx.Data["HasPrincipalError"] = true
+ switch {
+ case models.IsErrKeyAlreadyExist(err), models.IsErrKeyNameAlreadyUsed(err):
+ loadKeysData(ctx)
+
+ ctx.Data["Err_Content"] = true
+ ctx.RenderWithErr(ctx.Tr("settings.ssh_principal_been_used"), tplSettingsKeys, &form)
+ default:
+ ctx.ServerError("AddPrincipalKey", err)
+ }
+ return
+ }
+ ctx.Flash.Success(ctx.Tr("settings.add_principal_success", form.Content))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/keys")
+ case "gpg":
+ keys, 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):
+ loadKeysData(ctx)
+
+ ctx.Data["Err_Content"] = true
+ ctx.RenderWithErr(ctx.Tr("settings.gpg_key_id_used"), tplSettingsKeys, &form)
+ case models.IsErrGPGNoEmailFound(err):
+ loadKeysData(ctx)
+
+ ctx.Data["Err_Content"] = true
+ ctx.RenderWithErr(ctx.Tr("settings.gpg_no_key_email_found"), tplSettingsKeys, &form)
+ default:
+ ctx.ServerError("AddPublicKey", err)
+ }
+ return
+ }
+ keyIDs := ""
+ for _, key := range keys {
+ keyIDs += key.KeyID
+ keyIDs += ", "
+ }
+ if len(keyIDs) > 0 {
+ keyIDs = keyIDs[:len(keyIDs)-2]
+ }
+ ctx.Flash.Success(ctx.Tr("settings.add_gpg_key_success", keyIDs))
+ 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, 0); err != nil {
+ ctx.Data["HasSSHError"] = true
+ switch {
+ case models.IsErrKeyAlreadyExist(err):
+ loadKeysData(ctx)
+
+ ctx.Data["Err_Content"] = true
+ ctx.RenderWithErr(ctx.Tr("settings.ssh_key_been_used"), tplSettingsKeys, &form)
+ case models.IsErrKeyNameAlreadyUsed(err):
+ loadKeysData(ctx)
+
+ ctx.Data["Err_Title"] = true
+ ctx.RenderWithErr(ctx.Tr("settings.ssh_key_name_used"), tplSettingsKeys, &form)
+ case models.IsErrKeyUnableVerify(err):
+ ctx.Flash.Info(ctx.Tr("form.unable_verify_ssh_key"))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/keys")
+ 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":
+ keyID := ctx.QueryInt64("id")
+ external, err := models.PublicKeyIsExternallyManaged(keyID)
+ if err != nil {
+ ctx.ServerError("sshKeysExternalManaged", err)
+ return
+ }
+ if external {
+ ctx.Flash.Error(ctx.Tr("setting.ssh_externally_managed"))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/keys")
+ return
+ }
+ if err := models.DeletePublicKey(ctx.User, keyID); err != nil {
+ ctx.Flash.Error("DeletePublicKey: " + err.Error())
+ } else {
+ ctx.Flash.Success(ctx.Tr("settings.ssh_key_deletion_success"))
+ }
+ case "principal":
+ 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_principal_deletion_success"))
+ }
+ default:
+ ctx.Flash.Warning("Function not implemented")
+ ctx.Redirect(setting.AppSubURL + "/user/settings/keys")
+ }
+ ctx.JSON(http.StatusOK, map[string]interface{}{
+ "redirect": setting.AppSubURL + "/user/settings/keys",
+ })
+}
+
+func loadKeysData(ctx *context.Context) {
+ keys, err := models.ListPublicKeys(ctx.User.ID, models.ListOptions{})
+ if err != nil {
+ ctx.ServerError("ListPublicKeys", err)
+ return
+ }
+ ctx.Data["Keys"] = keys
+
+ externalKeys, err := models.PublicKeysAreExternallyManaged(keys)
+ if err != nil {
+ ctx.ServerError("ListPublicKeys", err)
+ return
+ }
+ ctx.Data["ExternalKeys"] = externalKeys
+
+ gpgkeys, err := models.ListGPGKeys(ctx.User.ID, models.ListOptions{})
+ if err != nil {
+ ctx.ServerError("ListGPGKeys", err)
+ return
+ }
+ ctx.Data["GPGKeys"] = gpgkeys
+
+ principals, err := models.ListPrincipalKeys(ctx.User.ID, models.ListOptions{})
+ if err != nil {
+ ctx.ServerError("ListPrincipalKeys", err)
+ return
+ }
+ ctx.Data["Principals"] = principals
+}
diff --git a/routers/web/user/setting/main_test.go b/routers/web/user/setting/main_test.go
new file mode 100644
index 0000000000..daa3f7fe5b
--- /dev/null
+++ b/routers/web/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/web/user/setting/oauth2.go b/routers/web/user/setting/oauth2.go
new file mode 100644
index 0000000000..c8db6e87f2
--- /dev/null
+++ b/routers/web/user/setting/oauth2.go
@@ -0,0 +1,159 @@
+// Copyright 2019 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 (
+ "fmt"
+ "net/http"
+
+ "code.gitea.io/gitea/models"
+ "code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/context"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/services/forms"
+)
+
+const (
+ tplSettingsOAuthApplications base.TplName = "user/settings/applications_oauth2_edit"
+)
+
+// OAuthApplicationsPost response for adding a oauth2 application
+func OAuthApplicationsPost(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.EditOAuth2ApplicationForm)
+ ctx.Data["Title"] = ctx.Tr("settings")
+ ctx.Data["PageIsSettingsApplications"] = true
+
+ if ctx.HasError() {
+ loadApplicationsData(ctx)
+
+ ctx.HTML(http.StatusOK, tplSettingsApplications)
+ return
+ }
+ // TODO validate redirect URI
+ app, err := models.CreateOAuth2Application(models.CreateOAuth2ApplicationOptions{
+ Name: form.Name,
+ RedirectURIs: []string{form.RedirectURI},
+ UserID: ctx.User.ID,
+ })
+ if err != nil {
+ ctx.ServerError("CreateOAuth2Application", err)
+ return
+ }
+ ctx.Flash.Success(ctx.Tr("settings.create_oauth2_application_success"))
+ ctx.Data["App"] = app
+ ctx.Data["ClientSecret"], err = app.GenerateClientSecret()
+ if err != nil {
+ ctx.ServerError("GenerateClientSecret", err)
+ return
+ }
+ ctx.HTML(http.StatusOK, tplSettingsOAuthApplications)
+}
+
+// OAuthApplicationsEdit response for editing oauth2 application
+func OAuthApplicationsEdit(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.EditOAuth2ApplicationForm)
+ ctx.Data["Title"] = ctx.Tr("settings")
+ ctx.Data["PageIsSettingsApplications"] = true
+
+ if ctx.HasError() {
+ loadApplicationsData(ctx)
+
+ ctx.HTML(http.StatusOK, tplSettingsApplications)
+ return
+ }
+ // TODO validate redirect URI
+ var err error
+ if ctx.Data["App"], err = models.UpdateOAuth2Application(models.UpdateOAuth2ApplicationOptions{
+ ID: ctx.ParamsInt64("id"),
+ Name: form.Name,
+ RedirectURIs: []string{form.RedirectURI},
+ UserID: ctx.User.ID,
+ }); err != nil {
+ ctx.ServerError("UpdateOAuth2Application", err)
+ return
+ }
+ ctx.Flash.Success(ctx.Tr("settings.update_oauth2_application_success"))
+ ctx.HTML(http.StatusOK, tplSettingsOAuthApplications)
+}
+
+// OAuthApplicationsRegenerateSecret handles the post request for regenerating the secret
+func OAuthApplicationsRegenerateSecret(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("settings")
+ ctx.Data["PageIsSettingsApplications"] = true
+
+ app, err := models.GetOAuth2ApplicationByID(ctx.ParamsInt64("id"))
+ if err != nil {
+ if models.IsErrOAuthApplicationNotFound(err) {
+ ctx.NotFound("Application not found", err)
+ return
+ }
+ ctx.ServerError("GetOAuth2ApplicationByID", err)
+ return
+ }
+ if app.UID != ctx.User.ID {
+ ctx.NotFound("Application not found", nil)
+ return
+ }
+ ctx.Data["App"] = app
+ ctx.Data["ClientSecret"], err = app.GenerateClientSecret()
+ if err != nil {
+ ctx.ServerError("GenerateClientSecret", err)
+ return
+ }
+ ctx.Flash.Success(ctx.Tr("settings.update_oauth2_application_success"))
+ ctx.HTML(http.StatusOK, tplSettingsOAuthApplications)
+}
+
+// OAuth2ApplicationShow displays the given application
+func OAuth2ApplicationShow(ctx *context.Context) {
+ app, err := models.GetOAuth2ApplicationByID(ctx.ParamsInt64("id"))
+ if err != nil {
+ if models.IsErrOAuthApplicationNotFound(err) {
+ ctx.NotFound("Application not found", err)
+ return
+ }
+ ctx.ServerError("GetOAuth2ApplicationByID", err)
+ return
+ }
+ if app.UID != ctx.User.ID {
+ ctx.NotFound("Application not found", nil)
+ return
+ }
+ ctx.Data["App"] = app
+ ctx.HTML(http.StatusOK, tplSettingsOAuthApplications)
+}
+
+// DeleteOAuth2Application deletes the given oauth2 application
+func DeleteOAuth2Application(ctx *context.Context) {
+ if err := models.DeleteOAuth2Application(ctx.QueryInt64("id"), ctx.User.ID); err != nil {
+ ctx.ServerError("DeleteOAuth2Application", err)
+ return
+ }
+ log.Trace("OAuth2 Application deleted: %s", ctx.User.Name)
+
+ ctx.Flash.Success(ctx.Tr("settings.remove_oauth2_application_success"))
+ ctx.JSON(http.StatusOK, map[string]interface{}{
+ "redirect": setting.AppSubURL + "/user/settings/applications",
+ })
+}
+
+// RevokeOAuth2Grant revokes the grant with the given id
+func RevokeOAuth2Grant(ctx *context.Context) {
+ if ctx.User.ID == 0 || ctx.QueryInt64("id") == 0 {
+ ctx.ServerError("RevokeOAuth2Grant", fmt.Errorf("user id or grant id is zero"))
+ return
+ }
+ if err := models.RevokeOAuth2Grant(ctx.QueryInt64("id"), ctx.User.ID); err != nil {
+ ctx.ServerError("RevokeOAuth2Grant", err)
+ return
+ }
+
+ ctx.Flash.Success(ctx.Tr("settings.revoke_oauth2_grant_success"))
+ ctx.JSON(http.StatusOK, map[string]interface{}{
+ "redirect": setting.AppSubURL + "/user/settings/applications",
+ })
+}
diff --git a/routers/web/user/setting/profile.go b/routers/web/user/setting/profile.go
new file mode 100644
index 0000000000..20042caca4
--- /dev/null
+++ b/routers/web/user/setting/profile.go
@@ -0,0 +1,319 @@
+// 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"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "code.gitea.io/gitea/models"
+ "code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/context"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/typesniffer"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/modules/web/middleware"
+ "code.gitea.io/gitea/services/forms"
+
+ "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(http.StatusOK, tplSettingsProfile)
+}
+
+// HandleUsernameChange handle username changes from user settings and admin interface
+func HandleUsernameChange(ctx *context.Context, user *models.User, newName string) error {
+ // Non-local users are not allowed to change their username.
+ if !user.IsLocal() {
+ ctx.Flash.Error(ctx.Tr("form.username_change_not_local_user"))
+ return fmt.Errorf(ctx.Tr("form.username_change_not_local_user"))
+ }
+
+ // Check if user name has been changed
+ if user.LowerName != strings.ToLower(newName) {
+ if err := models.ChangeUserName(user, newName); err != nil {
+ switch {
+ case models.IsErrUserAlreadyExist(err):
+ ctx.Flash.Error(ctx.Tr("form.username_been_taken"))
+ case models.IsErrEmailAlreadyUsed(err):
+ ctx.Flash.Error(ctx.Tr("form.email_been_used"))
+ case models.IsErrNameReserved(err):
+ ctx.Flash.Error(ctx.Tr("user.form.name_reserved", newName))
+ case models.IsErrNamePatternNotAllowed(err):
+ ctx.Flash.Error(ctx.Tr("user.form.name_pattern_not_allowed", newName))
+ case models.IsErrNameCharsNotAllowed(err):
+ ctx.Flash.Error(ctx.Tr("user.form.name_chars_not_allowed", newName))
+ default:
+ ctx.ServerError("ChangeUserName", err)
+ }
+ return err
+ }
+ } else {
+ if err := models.UpdateRepositoryOwnerNames(user.ID, newName); err != nil {
+ ctx.ServerError("UpdateRepository", err)
+ return err
+ }
+ }
+ log.Trace("User name changed: %s -> %s", user.Name, newName)
+ return nil
+}
+
+// ProfilePost response for change user's profile
+func ProfilePost(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.UpdateProfileForm)
+ ctx.Data["Title"] = ctx.Tr("settings")
+ ctx.Data["PageIsSettingsProfile"] = true
+
+ if ctx.HasError() {
+ ctx.HTML(http.StatusOK, tplSettingsProfile)
+ return
+ }
+
+ if len(form.Name) != 0 && ctx.User.Name != form.Name {
+ log.Debug("Changing name for %s to %s", ctx.User.Name, form.Name)
+ if err := HandleUsernameChange(ctx, ctx.User, form.Name); err != nil {
+ ctx.Redirect(setting.AppSubURL + "/user/settings")
+ return
+ }
+ ctx.User.Name = form.Name
+ ctx.User.LowerName = strings.ToLower(form.Name)
+ }
+
+ ctx.User.FullName = form.FullName
+ ctx.User.KeepEmailPrivate = form.KeepEmailPrivate
+ ctx.User.Website = form.Website
+ ctx.User.Location = form.Location
+ if len(form.Language) != 0 {
+ if !util.IsStringInSlice(form.Language, setting.Langs) {
+ ctx.Flash.Error(ctx.Tr("settings.update_language_not_found", form.Language))
+ ctx.Redirect(setting.AppSubURL + "/user/settings")
+ return
+ }
+ ctx.User.Language = form.Language
+ }
+ ctx.User.Description = form.Description
+ ctx.User.KeepActivityPrivate = form.KeepActivityPrivate
+ 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
+ middleware.SetLocaleCookie(ctx.Resp, ctx.User.Language, 0)
+
+ 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 *forms.AvatarForm, ctxUser *models.User) error {
+ ctxUser.UseCustomAvatar = form.Source == forms.AvatarLocal
+ if len(form.Gravatar) > 0 {
+ if form.Avatar != nil {
+ ctxUser.Avatar = base.EncodeMD5(form.Gravatar)
+ } else {
+ ctxUser.Avatar = ""
+ }
+ ctxUser.AvatarEmail = form.Gravatar
+ }
+
+ if form.Avatar != nil && form.Avatar.Filename != "" {
+ fr, err := form.Avatar.Open()
+ if err != nil {
+ return fmt.Errorf("Avatar.Open: %v", err)
+ }
+ defer fr.Close()
+
+ if form.Avatar.Size > setting.Avatar.MaxFileSize {
+ return errors.New(ctx.Tr("settings.uploaded_avatar_is_too_big"))
+ }
+
+ data, err := ioutil.ReadAll(fr)
+ if err != nil {
+ return fmt.Errorf("ioutil.ReadAll: %v", err)
+ }
+
+ st := typesniffer.DetectContentType(data)
+ if !(st.IsImage() && !st.IsSvgImage()) {
+ 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 if ctxUser.UseCustomAvatar && ctxUser.Avatar == "" {
+ // No avatar is uploaded but setting has been changed to enable,
+ // generate a random one when needed.
+ if err := ctxUser.GenerateRandomAvatar(); err != nil {
+ log.Error("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 := web.GetForm(ctx).(*forms.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(http.StatusOK, 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
+ ctx.Data["allowAdopt"] = ctx.IsUserSiteAdmin() || setting.Repository.AllowAdoptionOfUnadoptedRepositories
+ ctx.Data["allowDelete"] = ctx.IsUserSiteAdmin() || setting.Repository.AllowDeleteOfUnadoptedRepositories
+
+ opts := models.ListOptions{
+ PageSize: setting.UI.Admin.UserPagingNum,
+ Page: ctx.QueryInt("page"),
+ }
+
+ if opts.Page <= 0 {
+ opts.Page = 1
+ }
+ start := (opts.Page - 1) * opts.PageSize
+ end := start + opts.PageSize
+
+ adoptOrDelete := ctx.IsUserSiteAdmin() || (setting.Repository.AllowAdoptionOfUnadoptedRepositories && setting.Repository.AllowDeleteOfUnadoptedRepositories)
+
+ ctxUser := ctx.User
+ count := 0
+
+ if adoptOrDelete {
+ repoNames := make([]string, 0, setting.UI.Admin.UserPagingNum)
+ repos := map[string]*models.Repository{}
+ // We're going to iterate by pagesize.
+ root := filepath.Join(models.UserPath(ctxUser.Name))
+ if err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil
+ }
+ return err
+ }
+ if !info.IsDir() || path == root {
+ return nil
+ }
+ name := info.Name()
+ if !strings.HasSuffix(name, ".git") {
+ return filepath.SkipDir
+ }
+ name = name[:len(name)-4]
+ if models.IsUsableRepoName(name) != nil || strings.ToLower(name) != name {
+ return filepath.SkipDir
+ }
+ if count >= start && count < end {
+ repoNames = append(repoNames, name)
+ }
+ count++
+ return filepath.SkipDir
+ }); err != nil {
+ ctx.ServerError("filepath.Walk", err)
+ return
+ }
+
+ if err := ctxUser.GetRepositories(models.ListOptions{Page: 1, PageSize: setting.UI.Admin.UserPagingNum}, repoNames...); err != nil {
+ ctx.ServerError("GetRepositories", err)
+ return
+ }
+ for _, repo := range ctxUser.Repos {
+ if repo.IsFork {
+ if err := repo.GetBaseRepo(); err != nil {
+ ctx.ServerError("GetBaseRepo", err)
+ return
+ }
+ }
+ repos[repo.LowerName] = repo
+ }
+ ctx.Data["Dirs"] = repoNames
+ ctx.Data["ReposMap"] = repos
+ } else {
+ var err error
+ var count64 int64
+ ctxUser.Repos, count64, err = models.GetUserRepositories(&models.SearchRepoOptions{Actor: ctxUser, Private: true, ListOptions: opts})
+
+ if err != nil {
+ ctx.ServerError("GetRepositories", err)
+ return
+ }
+ count = int(count64)
+ repos := ctxUser.Repos
+
+ for i := range repos {
+ if repos[i].IsFork {
+ if err := repos[i].GetBaseRepo(); err != nil {
+ ctx.ServerError("GetBaseRepo", err)
+ return
+ }
+ }
+ }
+
+ ctx.Data["Repos"] = repos
+ }
+ ctx.Data["Owner"] = ctxUser
+ pager := context.NewPagination(int(count), opts.PageSize, opts.Page, 5)
+ pager.SetDefaultParams(ctx)
+ ctx.Data["Page"] = pager
+ ctx.HTML(http.StatusOK, tplSettingsRepositories)
+}
diff --git a/routers/web/user/setting/security.go b/routers/web/user/setting/security.go
new file mode 100644
index 0000000000..7753c5c161
--- /dev/null
+++ b/routers/web/user/setting/security.go
@@ -0,0 +1,111 @@
+// 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 (
+ "net/http"
+
+ "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
+ ctx.Data["RequireU2F"] = true
+
+ if ctx.Query("openid.return_to") != "" {
+ settingsOpenIDVerify(ctx)
+ return
+ }
+
+ loadSecurityData(ctx)
+
+ ctx.HTML(http.StatusOK, tplSettingsSecurity)
+}
+
+// DeleteAccountLink delete a single account link
+func DeleteAccountLink(ctx *context.Context) {
+ id := ctx.QueryInt64("id")
+ if id <= 0 {
+ ctx.Flash.Error("Account link id is not given")
+ } else {
+ if _, err := models.RemoveAccountLink(ctx.User, id); err != nil {
+ ctx.Flash.Error("RemoveAccountLink: " + err.Error())
+ } else {
+ ctx.Flash.Success(ctx.Tr("settings.remove_account_link_success"))
+ }
+ }
+
+ ctx.JSON(http.StatusOK, map[string]interface{}{
+ "redirect": setting.AppSubURL + "/user/settings/security",
+ })
+}
+
+func loadSecurityData(ctx *context.Context) {
+ 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
+ if enrolled {
+ ctx.Data["U2FRegistrations"], err = models.GetU2FRegistrationsByUID(ctx.User.ID)
+ if err != nil {
+ ctx.ServerError("GetU2FRegistrationsByUID", err)
+ return
+ }
+ }
+
+ tokens, err := models.ListAccessTokens(models.ListAccessTokensOptions{UserID: 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
+
+ openid, err := models.GetUserOpenIDs(ctx.User.ID)
+ if err != nil {
+ ctx.ServerError("GetUserOpenIDs", err)
+ return
+ }
+ ctx.Data["OpenIDs"] = openid
+}
diff --git a/routers/web/user/setting/security_openid.go b/routers/web/user/setting/security_openid.go
new file mode 100644
index 0000000000..74dba12825
--- /dev/null
+++ b/routers/web/user/setting/security_openid.go
@@ -0,0 +1,129 @@
+// 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 (
+ "net/http"
+
+ "code.gitea.io/gitea/models"
+ "code.gitea.io/gitea/modules/auth/openid"
+ "code.gitea.io/gitea/modules/context"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/services/forms"
+)
+
+// OpenIDPost response for change user's openid
+func OpenIDPost(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.AddOpenIDForm)
+ ctx.Data["Title"] = ctx.Tr("settings")
+ ctx.Data["PageIsSettingsSecurity"] = true
+
+ if ctx.HasError() {
+ loadSecurityData(ctx)
+
+ ctx.HTML(http.StatusOK, tplSettingsSecurity)
+ return
+ }
+
+ // WARNING: specifying a wrong OpenID here could lock
+ // a user out of her account, would be better to
+ // verify/confirm the new OpenID before storing it
+
+ // Also, consider allowing for multiple OpenID URIs
+
+ id, err := openid.Normalize(form.Openid)
+ if err != nil {
+ loadSecurityData(ctx)
+
+ ctx.RenderWithErr(err.Error(), tplSettingsSecurity, &form)
+ return
+ }
+ form.Openid = id
+ log.Trace("Normalized id: " + id)
+
+ oids, err := models.GetUserOpenIDs(ctx.User.ID)
+ if err != nil {
+ ctx.ServerError("GetUserOpenIDs", err)
+ return
+ }
+ ctx.Data["OpenIDs"] = oids
+
+ // Check that the OpenID is not already used
+ for _, obj := range oids {
+ if obj.URI == id {
+ loadSecurityData(ctx)
+
+ ctx.RenderWithErr(ctx.Tr("form.openid_been_used", id), tplSettingsSecurity, &form)
+ return
+ }
+ }
+
+ redirectTo := setting.AppURL + "user/settings/security"
+ url, err := openid.RedirectURL(id, redirectTo, setting.AppURL)
+ if err != nil {
+ loadSecurityData(ctx)
+
+ ctx.RenderWithErr(err.Error(), tplSettingsSecurity, &form)
+ return
+ }
+ ctx.Redirect(url)
+}
+
+func settingsOpenIDVerify(ctx *context.Context) {
+ log.Trace("Incoming call to: " + ctx.Req.URL.String())
+
+ fullURL := setting.AppURL + ctx.Req.URL.String()[1:]
+ log.Trace("Full URL: " + fullURL)
+
+ id, err := openid.Verify(fullURL)
+ if err != nil {
+ ctx.RenderWithErr(err.Error(), tplSettingsSecurity, &forms.AddOpenIDForm{
+ Openid: id,
+ })
+ return
+ }
+
+ log.Trace("Verified ID: " + id)
+
+ oid := &models.UserOpenID{UID: ctx.User.ID, URI: id}
+ if err = models.AddUserOpenID(oid); err != nil {
+ if models.IsErrOpenIDAlreadyUsed(err) {
+ ctx.RenderWithErr(ctx.Tr("form.openid_been_used", id), tplSettingsSecurity, &forms.AddOpenIDForm{Openid: id})
+ return
+ }
+ ctx.ServerError("AddUserOpenID", err)
+ return
+ }
+ log.Trace("Associated OpenID %s to user %s", id, ctx.User.Name)
+ ctx.Flash.Success(ctx.Tr("settings.add_openid_success"))
+
+ ctx.Redirect(setting.AppSubURL + "/user/settings/security")
+}
+
+// DeleteOpenID response for delete user's openid
+func DeleteOpenID(ctx *context.Context) {
+ if err := models.DeleteUserOpenID(&models.UserOpenID{ID: ctx.QueryInt64("id"), UID: ctx.User.ID}); err != nil {
+ ctx.ServerError("DeleteUserOpenID", err)
+ return
+ }
+ log.Trace("OpenID address deleted: %s", ctx.User.Name)
+
+ ctx.Flash.Success(ctx.Tr("settings.openid_deletion_success"))
+ ctx.JSON(http.StatusOK, map[string]interface{}{
+ "redirect": setting.AppSubURL + "/user/settings/security",
+ })
+}
+
+// ToggleOpenIDVisibility response for toggle visibility of user's openid
+func ToggleOpenIDVisibility(ctx *context.Context) {
+ if err := models.ToggleUserOpenIDVisibility(ctx.QueryInt64("id")); err != nil {
+ ctx.ServerError("ToggleUserOpenIDVisibility", err)
+ return
+ }
+
+ ctx.Redirect(setting.AppSubURL + "/user/settings/security")
+}
diff --git a/routers/web/user/setting/security_twofa.go b/routers/web/user/setting/security_twofa.go
new file mode 100644
index 0000000000..7b08a05939
--- /dev/null
+++ b/routers/web/user/setting/security_twofa.go
@@ -0,0 +1,250 @@
+// 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"
+ "net/http"
+ "strings"
+
+ "code.gitea.io/gitea/models"
+ "code.gitea.io/gitea/modules/context"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/services/forms"
+
+ "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 {
+ if models.IsErrTwoFactorNotEnrolled(err) {
+ ctx.Flash.Error(ctx.Tr("setting.twofa_not_enrolled"))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/security")
+ }
+ ctx.ServerError("SettingsTwoFactor: Failed to GetTwoFactorByUID", err)
+ return
+ }
+
+ token, err := t.GenerateScratchToken()
+ if err != nil {
+ ctx.ServerError("SettingsTwoFactor: Failed to GenerateScratchToken", err)
+ return
+ }
+
+ if err = models.UpdateTwoFactor(t); err != nil {
+ ctx.ServerError("SettingsTwoFactor: Failed to UpdateTwoFactor", err)
+ return
+ }
+
+ ctx.Flash.Success(ctx.Tr("settings.twofa_scratch_token_regenerated", token))
+ 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 {
+ if models.IsErrTwoFactorNotEnrolled(err) {
+ ctx.Flash.Error(ctx.Tr("setting.twofa_not_enrolled"))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/security")
+ }
+ ctx.ServerError("SettingsTwoFactor: Failed to GetTwoFactorByUID", err)
+ return
+ }
+
+ if err = models.DeleteTwoFactorByID(t.ID, ctx.User.ID); err != nil {
+ if models.IsErrTwoFactorNotEnrolled(err) {
+ // There is a potential DB race here - we must have been disabled by another request in the intervening period
+ ctx.Flash.Success(ctx.Tr("settings.twofa_disabled"))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/security")
+ }
+ ctx.ServerError("SettingsTwoFactor: Failed to DeleteTwoFactorByID", 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 err != nil {
+ ctx.ServerError("SettingsTwoFactor: Failed NewKeyFromURL: ", err)
+ return false
+ }
+ }
+ // Filter unsafe character ':' in issuer
+ issuer := strings.ReplaceAll(setting.AppName+" ("+setting.Domain+")", ":", "")
+ if otpKey == nil {
+ otpKey, err = totp.Generate(totp.GenerateOpts{
+ SecretSize: 40,
+ Issuer: issuer,
+ AccountName: ctx.User.Name,
+ })
+ if err != nil {
+ ctx.ServerError("SettingsTwoFactor: totpGenerate Failed", err)
+ return false
+ }
+ }
+
+ ctx.Data["TwofaSecret"] = otpKey.Secret()
+ img, err := otpKey.Image(320, 240)
+ if err != nil {
+ ctx.ServerError("SettingsTwoFactor: otpKey image generation failed", err)
+ return false
+ }
+
+ var imgBytes bytes.Buffer
+ if err = png.Encode(&imgBytes, img); err != nil {
+ ctx.ServerError("SettingsTwoFactor: otpKey png encoding failed", err)
+ return false
+ }
+
+ ctx.Data["QrUri"] = template.URL("data:image/png;base64," + base64.StdEncoding.EncodeToString(imgBytes.Bytes()))
+
+ if err := ctx.Session.Set("twofaSecret", otpKey.Secret()); err != nil {
+ ctx.ServerError("SettingsTwoFactor: Failed to set session for twofaSecret", err)
+ return false
+ }
+
+ if err := ctx.Session.Set("twofaUri", otpKey.String()); err != nil {
+ ctx.ServerError("SettingsTwoFactor: Failed to set session for twofaUri", err)
+ return false
+ }
+
+ // Here we're just going to try to release the session early
+ if err := ctx.Session.Release(); err != nil {
+ // we'll tolerate errors here as they *should* get saved elsewhere
+ log.Error("Unable to save changes to the session: %v", err)
+ }
+ 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 - we should redirect back!
+ log.Warn("Trying to re-enroll %-v in twofa when already enrolled", ctx.User)
+ ctx.Flash.Error(ctx.Tr("setting.twofa_is_enrolled"))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/security")
+ return
+ }
+ if err != nil && !models.IsErrTwoFactorNotEnrolled(err) {
+ ctx.ServerError("SettingsTwoFactor: GetTwoFactorByUID", err)
+ return
+ }
+
+ if !twofaGenerateSecretAndQr(ctx) {
+ return
+ }
+
+ ctx.HTML(http.StatusOK, tplSettingsTwofaEnroll)
+}
+
+// EnrollTwoFactorPost handles enrolling the user into 2FA.
+func EnrollTwoFactorPost(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.TwoFactorAuthForm)
+ ctx.Data["Title"] = ctx.Tr("settings")
+ ctx.Data["PageIsSettingsSecurity"] = true
+
+ t, err := models.GetTwoFactorByUID(ctx.User.ID)
+ if t != nil {
+ // already enrolled
+ ctx.Flash.Error(ctx.Tr("setting.twofa_is_enrolled"))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/security")
+ return
+ }
+ if err != nil && !models.IsErrTwoFactorNotEnrolled(err) {
+ ctx.ServerError("SettingsTwoFactor: Failed to check if already enrolled with GetTwoFactorByUID", err)
+ return
+ }
+
+ if ctx.HasError() {
+ if !twofaGenerateSecretAndQr(ctx) {
+ return
+ }
+ ctx.HTML(http.StatusOK, tplSettingsTwofaEnroll)
+ return
+ }
+
+ secretRaw := ctx.Session.Get("twofaSecret")
+ if secretRaw == nil {
+ ctx.Flash.Error(ctx.Tr("settings.twofa_failed_get_secret"))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/security/two_factor/enroll")
+ return
+ }
+
+ secret := secretRaw.(string)
+ if !totp.Validate(form.Passcode, secret) {
+ if !twofaGenerateSecretAndQr(ctx) {
+ return
+ }
+ ctx.Flash.Error(ctx.Tr("settings.passcode_invalid"))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/security/two_factor/enroll")
+ return
+ }
+
+ t = &models.TwoFactor{
+ UID: ctx.User.ID,
+ }
+ err = t.SetSecret(secret)
+ if err != nil {
+ ctx.ServerError("SettingsTwoFactor: Failed to set secret", err)
+ return
+ }
+ token, err := t.GenerateScratchToken()
+ if err != nil {
+ ctx.ServerError("SettingsTwoFactor: Failed to generate scratch token", err)
+ return
+ }
+
+ // Now we have to delete the secrets - because if we fail to insert then it's highly likely that they have already been used
+ // If we can detect the unique constraint failure below we can move this to after the NewTwoFactor
+ if err := ctx.Session.Delete("twofaSecret"); err != nil {
+ // tolerate this failure - it's more important to continue
+ log.Error("Unable to delete twofaSecret from the session: Error: %v", err)
+ }
+ if err := ctx.Session.Delete("twofaUri"); err != nil {
+ // tolerate this failure - it's more important to continue
+ log.Error("Unable to delete twofaUri from the session: Error: %v", err)
+ }
+ if err := ctx.Session.Release(); err != nil {
+ // tolerate this failure - it's more important to continue
+ log.Error("Unable to save changes to the session: %v", err)
+ }
+
+ if err = models.NewTwoFactor(t); err != nil {
+ // FIXME: We need to handle a unique constraint fail here it's entirely possible that another request has beaten us.
+ // If there is a unique constraint fail we should just tolerate the error
+ ctx.ServerError("SettingsTwoFactor: Failed to save two factor", err)
+ return
+ }
+
+ ctx.Flash.Success(ctx.Tr("settings.twofa_enrolled", token))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/security")
+}
diff --git a/routers/web/user/setting/security_u2f.go b/routers/web/user/setting/security_u2f.go
new file mode 100644
index 0000000000..f9e35549fb
--- /dev/null
+++ b/routers/web/user/setting/security_u2f.go
@@ -0,0 +1,111 @@
+// 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"
+ "net/http"
+
+ "code.gitea.io/gitea/models"
+ "code.gitea.io/gitea/modules/context"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/services/forms"
+
+ "github.com/tstranex/u2f"
+)
+
+// U2FRegister initializes the u2f registration procedure
+func U2FRegister(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.U2FRegistrationForm)
+ if form.Name == "" {
+ ctx.Error(http.StatusConflict)
+ return
+ }
+ challenge, err := u2f.NewChallenge(setting.U2F.AppID, setting.U2F.TrustedFacets)
+ if err != nil {
+ ctx.ServerError("NewChallenge", err)
+ return
+ }
+ if err := ctx.Session.Set("u2fChallenge", challenge); err != nil {
+ ctx.ServerError("Unable to set session key for u2fChallenge", err)
+ return
+ }
+ regs, err := models.GetU2FRegistrationsByUID(ctx.User.ID)
+ if err != nil {
+ ctx.ServerError("GetU2FRegistrationsByUID", err)
+ return
+ }
+ for _, reg := range regs {
+ if reg.Name == form.Name {
+ ctx.Error(http.StatusConflict, "Name already taken")
+ return
+ }
+ }
+ if err := ctx.Session.Set("u2fName", form.Name); err != nil {
+ ctx.ServerError("Unable to set session key for u2fName", err)
+ return
+ }
+ // Here we're just going to try to release the session early
+ if err := ctx.Session.Release(); err != nil {
+ // we'll tolerate errors here as they *should* get saved elsewhere
+ log.Error("Unable to save changes to the session: %v", err)
+ }
+ ctx.JSON(http.StatusOK, u2f.NewWebRegisterRequest(challenge, regs.ToRegistrations()))
+}
+
+// U2FRegisterPost receives the response of the security key
+func U2FRegisterPost(ctx *context.Context) {
+ response := web.GetForm(ctx).(*u2f.RegisterResponse)
+ challSess := ctx.Session.Get("u2fChallenge")
+ u2fName := ctx.Session.Get("u2fName")
+ if challSess == nil || u2fName == nil {
+ ctx.ServerError("U2FRegisterPost", errors.New("not in U2F session"))
+ return
+ }
+ challenge := challSess.(*u2f.Challenge)
+ name := u2fName.(string)
+ config := &u2f.Config{
+ // Chrome 66+ doesn't return the device's attestation
+ // certificate by default.
+ SkipAttestationVerify: true,
+ }
+ reg, err := u2f.Register(*response, *challenge, config)
+ if err != nil {
+ ctx.ServerError("u2f.Register", err)
+ return
+ }
+ if _, err = models.CreateRegistration(ctx.User, name, reg); err != nil {
+ ctx.ServerError("u2f.Register", err)
+ return
+ }
+ ctx.Status(200)
+}
+
+// U2FDelete deletes an security key by id
+func U2FDelete(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.U2FDeleteForm)
+ reg, err := models.GetU2FRegistrationByID(form.ID)
+ if err != nil {
+ if models.IsErrU2FRegistrationNotExist(err) {
+ ctx.Status(200)
+ return
+ }
+ ctx.ServerError("GetU2FRegistrationByID", err)
+ return
+ }
+ if reg.UserID != ctx.User.ID {
+ ctx.Status(401)
+ return
+ }
+ if err := models.DeleteRegistration(reg); err != nil {
+ ctx.ServerError("DeleteRegistration", err)
+ return
+ }
+ ctx.JSON(http.StatusOK, map[string]interface{}{
+ "redirect": setting.AppSubURL + "/user/settings/security",
+ })
+}
diff --git a/routers/web/user/task.go b/routers/web/user/task.go
new file mode 100644
index 0000000000..b8df5d99c7
--- /dev/null
+++ b/routers/web/user/task.go
@@ -0,0 +1,32 @@
+// Copyright 2020 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
+
+import (
+ "net/http"
+
+ "code.gitea.io/gitea/models"
+ "code.gitea.io/gitea/modules/context"
+)
+
+// TaskStatus returns task's status
+func TaskStatus(ctx *context.Context) {
+ task, opts, err := models.GetMigratingTaskByID(ctx.ParamsInt64("task"), ctx.User.ID)
+ if err != nil {
+ ctx.JSON(http.StatusInternalServerError, map[string]interface{}{
+ "err": err,
+ })
+ return
+ }
+
+ ctx.JSON(http.StatusOK, map[string]interface{}{
+ "status": task.Status,
+ "err": task.Errors,
+ "repo-id": task.RepoID,
+ "repo-name": opts.RepoName,
+ "start": task.StartTime,
+ "end": task.EndTime,
+ })
+}