summaryrefslogtreecommitdiffstats
path: root/routers/web/user/setting
diff options
context:
space:
mode:
authorDaniel Baumann <daniel@debian.org>2024-10-18 20:33:49 +0200
committerDaniel Baumann <daniel@debian.org>2024-10-18 20:33:49 +0200
commitdd136858f1ea40ad3c94191d647487fa4f31926c (patch)
tree58fec94a7b2a12510c9664b21793f1ed560c6518 /routers/web/user/setting
parentInitial commit. (diff)
downloadforgejo-dd136858f1ea40ad3c94191d647487fa4f31926c.tar.xz
forgejo-dd136858f1ea40ad3c94191d647487fa4f31926c.zip
Adding upstream version 9.0.0.
Signed-off-by: Daniel Baumann <daniel@debian.org>
Diffstat (limited to 'routers/web/user/setting')
-rw-r--r--routers/web/user/setting/account.go344
-rw-r--r--routers/web/user/setting/account_test.go101
-rw-r--r--routers/web/user/setting/adopt.go64
-rw-r--r--routers/web/user/setting/applications.go115
-rw-r--r--routers/web/user/setting/blocked_users.go46
-rw-r--r--routers/web/user/setting/keys.go338
-rw-r--r--routers/web/user/setting/main_test.go14
-rw-r--r--routers/web/user/setting/oauth2.go68
-rw-r--r--routers/web/user/setting/oauth2_common.go163
-rw-r--r--routers/web/user/setting/packages.go119
-rw-r--r--routers/web/user/setting/profile.go433
-rw-r--r--routers/web/user/setting/runner.go13
-rw-r--r--routers/web/user/setting/security/2fa.go260
-rw-r--r--routers/web/user/setting/security/openid.go126
-rw-r--r--routers/web/user/setting/security/security.go148
-rw-r--r--routers/web/user/setting/security/webauthn.go137
-rw-r--r--routers/web/user/setting/webhooks.go49
17 files changed, 2538 insertions, 0 deletions
diff --git a/routers/web/user/setting/account.go b/routers/web/user/setting/account.go
new file mode 100644
index 0000000..3a2527c
--- /dev/null
+++ b/routers/web/user/setting/account.go
@@ -0,0 +1,344 @@
+// Copyright 2014 The Gogs Authors. All rights reserved.
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "errors"
+ "net/http"
+ "time"
+
+ "code.gitea.io/gitea/models"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/auth/password"
+ "code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/optional"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/timeutil"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/services/auth"
+ "code.gitea.io/gitea/services/auth/source/db"
+ "code.gitea.io/gitea/services/auth/source/smtp"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/forms"
+ "code.gitea.io/gitea/services/mailer"
+ "code.gitea.io/gitea/services/user"
+)
+
+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.account")
+ ctx.Data["PageIsSettingsAccount"] = true
+ ctx.Data["Email"] = ctx.Doer.Email
+ ctx.Data["EnableNotifyMail"] = setting.Service.EnableNotifyMail
+
+ 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 ctx.Doer.IsPasswordSet() && !ctx.Doer.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 {
+ opts := &user.UpdateAuthOptions{
+ Password: optional.Some(form.Password),
+ MustChangePassword: optional.Some(false),
+ }
+ if err := user.UpdateAuth(ctx, ctx.Doer, opts); err != nil {
+ switch {
+ case errors.Is(err, password.ErrMinLength):
+ ctx.Flash.Error(ctx.Tr("auth.password_too_short", setting.MinPasswordLength))
+ case errors.Is(err, password.ErrComplexity):
+ ctx.Flash.Error(password.BuildComplexityError(ctx.Locale))
+ case errors.Is(err, password.ErrIsPwned):
+ ctx.Flash.Error(ctx.Tr("auth.password_pwned", "https://haveibeenpwned.com/Passwords"))
+ case password.IsErrIsPwnedRequest(err):
+ ctx.Flash.Error(ctx.Tr("auth.password_pwned_err"))
+ default:
+ ctx.ServerError("UpdateAuth", err)
+ return
+ }
+ } else {
+ // Re-generate LTA cookie.
+ if len(ctx.GetSiteCookie(setting.CookieRememberName)) != 0 {
+ if err := ctx.SetLTACookie(ctx.Doer); err != nil {
+ ctx.ServerError("SetLTACookie", err)
+ return
+ }
+ }
+
+ log.Trace("User password updated: %s", ctx.Doer.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.FormString("_method") == "PRIMARY" {
+ id := ctx.FormInt64("id")
+ email, err := user_model.GetEmailAddressByID(ctx, ctx.Doer.ID, id)
+ if err != nil {
+ log.Error("GetEmailAddressByID(%d,%d) error: %v", ctx.Doer.ID, id, err)
+ ctx.Redirect(setting.AppSubURL + "/user/settings/account")
+ return
+ }
+
+ if err := user.MakeEmailAddressPrimary(ctx, ctx.Doer, email, true); err != nil {
+ ctx.ServerError("MakeEmailPrimary", err)
+ return
+ }
+
+ log.Trace("Email made primary: %s", ctx.Doer.Name)
+ ctx.Redirect(setting.AppSubURL + "/user/settings/account")
+ return
+ }
+ // Send activation Email
+ if ctx.FormString("_method") == "SENDACTIVATION" {
+ var address string
+ if ctx.Cache.IsExist("MailResendLimit_" + ctx.Doer.LowerName) {
+ log.Error("Send activation: activation still pending")
+ ctx.Redirect(setting.AppSubURL + "/user/settings/account")
+ return
+ }
+
+ id := ctx.FormInt64("id")
+ email, err := user_model.GetEmailAddressByID(ctx, ctx.Doer.ID, id)
+ if err != nil {
+ log.Error("GetEmailAddressByID(%d,%d) error: %v", ctx.Doer.ID, id, err)
+ ctx.Redirect(setting.AppSubURL + "/user/settings/account")
+ return
+ }
+ if email == nil {
+ log.Warn("Send activation failed: EmailAddress[%d] not found for user: %-v", id, ctx.Doer)
+ ctx.Redirect(setting.AppSubURL + "/user/settings/account")
+ return
+ }
+ if email.IsActivated {
+ log.Debug("Send activation failed: email %s is already activated for user: %-v", email.Email, ctx.Doer)
+ ctx.Redirect(setting.AppSubURL + "/user/settings/account")
+ return
+ }
+ if email.IsPrimary {
+ if ctx.Doer.IsActive && !setting.Service.RegisterEmailConfirm {
+ log.Debug("Send activation failed: email %s is already activated for user: %-v", email.Email, ctx.Doer)
+ ctx.Redirect(setting.AppSubURL + "/user/settings/account")
+ return
+ }
+ // Only fired when the primary email is inactive (Wrong state)
+ mailer.SendActivateAccountMail(ctx.Locale, ctx.Doer)
+ } else {
+ mailer.SendActivateEmailMail(ctx.Doer, email.Email)
+ }
+ address = email.Email
+
+ if err := ctx.Cache.Put("MailResendLimit_"+ctx.Doer.LowerName, ctx.Doer.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)))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/account")
+ return
+ }
+ // Set Email Notification Preference
+ if ctx.FormString("_method") == "NOTIFICATION" {
+ preference := ctx.FormString("preference")
+ if !(preference == user_model.EmailNotificationsEnabled ||
+ preference == user_model.EmailNotificationsOnMention ||
+ preference == user_model.EmailNotificationsDisabled ||
+ preference == user_model.EmailNotificationsAndYourOwn) {
+ log.Error("Email notifications preference change returned unrecognized option %s: %s", preference, ctx.Doer.Name)
+ ctx.ServerError("SetEmailPreference", errors.New("option unrecognized"))
+ return
+ }
+ opts := &user.UpdateOptions{
+ EmailNotificationsPreference: optional.Some(preference),
+ }
+ if err := user.UpdateUser(ctx, ctx.Doer, opts); err != nil {
+ log.Error("Set Email Notifications failed: %v", err)
+ ctx.ServerError("UpdateUser", err)
+ return
+ }
+ log.Trace("Email notifications preference made %s: %s", preference, ctx.Doer.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
+ }
+
+ if err := user.AddEmailAddresses(ctx, ctx.Doer, []string{form.Email}); err != nil {
+ if user_model.IsErrEmailAlreadyUsed(err) {
+ loadAccountData(ctx)
+
+ ctx.RenderWithErr(ctx.Tr("form.email_been_used"), tplSettingsAccount, &form)
+ } else if user_model.IsErrEmailCharIsNotSupported(err) || user_model.IsErrEmailInvalid(err) {
+ loadAccountData(ctx)
+
+ ctx.RenderWithErr(ctx.Tr("form.email_invalid"), tplSettingsAccount, &form)
+ } else {
+ ctx.ServerError("AddEmailAddresses", err)
+ }
+ return
+ }
+
+ // Send confirmation email
+ if setting.Service.RegisterEmailConfirm {
+ mailer.SendActivateEmailMail(ctx.Doer, form.Email)
+ if err := ctx.Cache.Put("MailResendLimit_"+ctx.Doer.LowerName, ctx.Doer.LowerName, 180); err != nil {
+ log.Error("Set cache(MailResendLimit) fail: %v", err)
+ }
+
+ ctx.Flash.Info(ctx.Tr("settings.add_email_confirmation_sent", form.Email, timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, ctx.Locale)))
+ } else {
+ ctx.Flash.Success(ctx.Tr("settings.add_email_success"))
+ }
+
+ log.Trace("Email address added: %s", form.Email)
+ ctx.Redirect(setting.AppSubURL + "/user/settings/account")
+}
+
+// DeleteEmail response for delete user's email
+func DeleteEmail(ctx *context.Context) {
+ email, err := user_model.GetEmailAddressByID(ctx, ctx.Doer.ID, ctx.FormInt64("id"))
+ if err != nil || email == nil {
+ ctx.ServerError("GetEmailAddressByID", err)
+ return
+ }
+
+ if err := user.DeleteEmailAddresses(ctx, ctx.Doer, []string{email.Email}); err != nil {
+ ctx.ServerError("DeleteEmailAddresses", err)
+ return
+ }
+ log.Trace("Email address deleted: %s", ctx.Doer.Name)
+
+ ctx.Flash.Success(ctx.Tr("settings.email_deletion_success"))
+ ctx.JSONRedirect(setting.AppSubURL + "/user/settings/account")
+}
+
+// DeleteAccount render user suicide page and response for delete user himself
+func DeleteAccount(ctx *context.Context) {
+ if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureDeletion) {
+ ctx.Error(http.StatusNotFound)
+ return
+ }
+
+ ctx.Data["Title"] = ctx.Tr("settings")
+ ctx.Data["PageIsSettingsAccount"] = true
+
+ if _, _, err := auth.UserSignIn(ctx, ctx.Doer.Name, ctx.FormString("password")); err != nil {
+ switch {
+ case user_model.IsErrUserNotExist(err):
+ loadAccountData(ctx)
+
+ ctx.RenderWithErr(ctx.Tr("form.user_not_exist"), tplSettingsAccount, nil)
+ case errors.Is(err, smtp.ErrUnsupportedLoginType):
+ loadAccountData(ctx)
+
+ ctx.RenderWithErr(ctx.Tr("form.unsupported_login_type"), tplSettingsAccount, nil)
+ case errors.As(err, &db.ErrUserPasswordNotSet{}):
+ loadAccountData(ctx)
+
+ ctx.RenderWithErr(ctx.Tr("form.unset_password"), tplSettingsAccount, nil)
+ case errors.As(err, &db.ErrUserPasswordInvalid{}):
+ loadAccountData(ctx)
+
+ ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_password"), tplSettingsAccount, nil)
+ default:
+ ctx.ServerError("UserSignIn", err)
+ }
+ return
+ }
+
+ // admin should not delete themself
+ if ctx.Doer.IsAdmin {
+ ctx.Flash.Error(ctx.Tr("form.admin_cannot_delete_self"))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/account")
+ return
+ }
+
+ if err := user.DeleteUser(ctx, ctx.Doer, false); 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")
+ case models.IsErrUserOwnPackages(err):
+ ctx.Flash.Error(ctx.Tr("form.still_own_packages"))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/account")
+ case models.IsErrDeleteLastAdminUser(err):
+ ctx.Flash.Error(ctx.Tr("auth.last_admin"))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/account")
+ default:
+ ctx.ServerError("DeleteUser", err)
+ }
+ } else {
+ log.Trace("Account deleted: %s", ctx.Doer.Name)
+ ctx.Redirect(setting.AppSubURL + "/")
+ }
+}
+
+func loadAccountData(ctx *context.Context) {
+ emlist, err := user_model.GetEmailAddresses(ctx, ctx.Doer.ID)
+ if err != nil {
+ ctx.ServerError("GetEmailAddresses", err)
+ return
+ }
+ type UserEmail struct {
+ user_model.EmailAddress
+ CanBePrimary bool
+ }
+ pendingActivation := ctx.Cache.IsExist("MailResendLimit_" + ctx.Doer.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.Doer.EmailNotificationsPreference
+ ctx.Data["ActivationsPending"] = pendingActivation
+ ctx.Data["CanAddEmails"] = !pendingActivation || !setting.Service.RegisterEmailConfirm
+ ctx.Data["UserDisabledFeatures"] = user_model.DisabledFeaturesWithLoginType(ctx.Doer)
+
+ if setting.Service.UserDeleteWithCommentsMaxTime != 0 {
+ ctx.Data["UserDeleteWithCommentsMaxTime"] = setting.Service.UserDeleteWithCommentsMaxTime.String()
+ ctx.Data["UserDeleteWithComments"] = ctx.Doer.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 0000000..9fdc5e4
--- /dev/null
+++ b/routers/web/user/setting/account_test.go
@@ -0,0 +1,101 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "net/http"
+ "testing"
+
+ "code.gitea.io/gitea/models/unittest"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/services/contexttest"
+ "code.gitea.io/gitea/services/forms"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestChangePassword(t *testing.T) {
+ oldPassword := "password"
+ setting.MinPasswordLength = 6
+ pcALL := []string{"lower", "upper", "digit", "spec"}
+ pcLUN := []string{"lower", "upper", "digit"}
+ 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,
+ },
+ } {
+ t.Run(req.OldPassword+"__"+req.NewPassword, func(t *testing.T) {
+ unittest.PrepareTestEnv(t)
+ setting.PasswordComplexity = req.PasswordComplexity
+ ctx, _ := contexttest.MockContext(t, "user/settings/security")
+ contexttest.LoadUser(t, ctx, 2)
+ contexttest.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.StatusSeeOther, ctx.Resp.Status())
+ })
+ }
+}
diff --git a/routers/web/user/setting/adopt.go b/routers/web/user/setting/adopt.go
new file mode 100644
index 0000000..171c193
--- /dev/null
+++ b/routers/web/user/setting/adopt.go
@@ -0,0 +1,64 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "path/filepath"
+
+ repo_model "code.gitea.io/gitea/models/repo"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/services/context"
+ repo_service "code.gitea.io/gitea/services/repository"
+)
+
+// AdoptOrDeleteRepository adopts or deletes a repository
+func AdoptOrDeleteRepository(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("settings.adopt")
+ 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.FormString("id")
+ action := ctx.FormString("action")
+
+ ctxUser := ctx.Doer
+ root := user_model.UserPath(ctxUser.LowerName)
+
+ // check not a repo
+ has, err := repo_model.IsRepositoryModelExist(ctx, 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 := repo_service.AdoptRepository(ctx, ctxUser, ctxUser, repo_service.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 := repo_service.DeleteUnadoptedRepository(ctx, 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 0000000..24ebf9b
--- /dev/null
+++ b/routers/web/user/setting/applications.go
@@ -0,0 +1,115 @@
+// Copyright 2014 The Gogs Authors. All rights reserved.
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "net/http"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/services/context"
+ "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.applications")
+ 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
+ }
+
+ scope, err := form.GetScope()
+ if err != nil {
+ ctx.ServerError("GetScope", err)
+ return
+ }
+ t := &auth_model.AccessToken{
+ UID: ctx.Doer.ID,
+ Name: form.Name,
+ Scope: scope,
+ }
+
+ exist, err := auth_model.AccessTokenByNameExists(ctx, 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 := auth_model.NewAccessToken(ctx, 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 := auth_model.DeleteAccessTokenByID(ctx, ctx.FormInt64("id"), ctx.Doer.ID); err != nil {
+ ctx.Flash.Error("DeleteAccessTokenByID: " + err.Error())
+ } else {
+ ctx.Flash.Success(ctx.Tr("settings.delete_token_success"))
+ }
+
+ ctx.JSONRedirect(setting.AppSubURL + "/user/settings/applications")
+}
+
+func loadApplicationsData(ctx *context.Context) {
+ ctx.Data["AccessTokenScopePublicOnly"] = auth_model.AccessTokenScopePublicOnly
+ tokens, err := db.Find[auth_model.AccessToken](ctx, auth_model.ListAccessTokensOptions{UserID: ctx.Doer.ID})
+ if err != nil {
+ ctx.ServerError("ListAccessTokens", err)
+ return
+ }
+ ctx.Data["Tokens"] = tokens
+ ctx.Data["EnableOAuth2"] = setting.OAuth2.Enabled
+ ctx.Data["IsAdmin"] = ctx.Doer.IsAdmin
+ if setting.OAuth2.Enabled {
+ ctx.Data["Applications"], err = db.Find[auth_model.OAuth2Application](ctx, auth_model.FindOAuth2ApplicationsOptions{
+ OwnerID: ctx.Doer.ID,
+ })
+ if err != nil {
+ ctx.ServerError("GetOAuth2ApplicationsByUserID", err)
+ return
+ }
+ ctx.Data["Grants"], err = auth_model.GetOAuth2GrantsByUserID(ctx, ctx.Doer.ID)
+ if err != nil {
+ ctx.ServerError("GetOAuth2GrantsByUserID", err)
+ return
+ }
+ ctx.Data["EnableAdditionalGrantScopes"] = setting.OAuth2.EnableAdditionalGrantScopes
+ }
+}
diff --git a/routers/web/user/setting/blocked_users.go b/routers/web/user/setting/blocked_users.go
new file mode 100644
index 0000000..3f35b2e
--- /dev/null
+++ b/routers/web/user/setting/blocked_users.go
@@ -0,0 +1,46 @@
+// Copyright 2023 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "net/http"
+
+ "code.gitea.io/gitea/models/db"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/services/context"
+)
+
+const (
+ tplSettingsBlockedUsers base.TplName = "user/settings/blocked_users"
+)
+
+// BlockedUsers render the blocked users list page.
+func BlockedUsers(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("settings.blocked_users")
+ ctx.Data["PageIsBlockedUsers"] = true
+ ctx.Data["BaseLink"] = setting.AppSubURL + "/user/settings/blocked_users"
+ ctx.Data["BaseLinkNew"] = setting.AppSubURL + "/user/settings/blocked_users"
+
+ blockedUsers, err := user_model.ListBlockedUsers(ctx, ctx.Doer.ID, db.ListOptions{})
+ if err != nil {
+ ctx.ServerError("ListBlockedUsers", err)
+ return
+ }
+
+ ctx.Data["BlockedUsers"] = blockedUsers
+ ctx.HTML(http.StatusOK, tplSettingsBlockedUsers)
+}
+
+// UnblockUser unblocks a particular user for the doer.
+func UnblockUser(ctx *context.Context) {
+ if err := user_model.UnblockUser(ctx, ctx.Doer.ID, ctx.FormInt64("user_id")); err != nil {
+ ctx.ServerError("UnblockUser", err)
+ return
+ }
+
+ ctx.Flash.Success(ctx.Tr("settings.user_unblock_success"))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/blocked_users")
+}
diff --git a/routers/web/user/setting/keys.go b/routers/web/user/setting/keys.go
new file mode 100644
index 0000000..9462be7
--- /dev/null
+++ b/routers/web/user/setting/keys.go
@@ -0,0 +1,338 @@
+// Copyright 2014 The Gogs Authors. All rights reserved.
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "fmt"
+ "net/http"
+
+ asymkey_model "code.gitea.io/gitea/models/asymkey"
+ "code.gitea.io/gitea/models/db"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/web"
+ asymkey_service "code.gitea.io/gitea/services/asymkey"
+ "code.gitea.io/gitea/services/context"
+ "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.ssh_gpg_keys")
+ 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 := asymkey_model.CheckPrincipalKeyString(ctx, ctx.Doer, form.Content)
+ if err != nil {
+ if db.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 = asymkey_model.AddPrincipalKey(ctx, ctx.Doer.ID, content, 0); err != nil {
+ ctx.Data["HasPrincipalError"] = true
+ switch {
+ case asymkey_model.IsErrKeyAlreadyExist(err), asymkey_model.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":
+ if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageGPGKeys) {
+ ctx.NotFound("Not Found", fmt.Errorf("gpg keys setting is not allowed to be visited"))
+ return
+ }
+
+ token := asymkey_model.VerificationToken(ctx.Doer, 1)
+ lastToken := asymkey_model.VerificationToken(ctx.Doer, 0)
+
+ keys, err := asymkey_model.AddGPGKey(ctx, ctx.Doer.ID, form.Content, token, form.Signature)
+ if err != nil && asymkey_model.IsErrGPGInvalidTokenSignature(err) {
+ keys, err = asymkey_model.AddGPGKey(ctx, ctx.Doer.ID, form.Content, lastToken, form.Signature)
+ }
+ if err != nil {
+ ctx.Data["HasGPGError"] = true
+ switch {
+ case asymkey_model.IsErrGPGKeyParsing(err):
+ ctx.Flash.Error(ctx.Tr("form.invalid_gpg_key", err.Error()))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/keys")
+ case asymkey_model.IsErrGPGKeyIDAlreadyUsed(err):
+ loadKeysData(ctx)
+
+ ctx.Data["Err_Content"] = true
+ ctx.RenderWithErr(ctx.Tr("settings.gpg_key_id_used"), tplSettingsKeys, &form)
+ case asymkey_model.IsErrGPGInvalidTokenSignature(err):
+ loadKeysData(ctx)
+ ctx.Data["Err_Content"] = true
+ ctx.Data["Err_Signature"] = true
+ keyID := err.(asymkey_model.ErrGPGInvalidTokenSignature).ID
+ ctx.Data["KeyID"] = keyID
+ ctx.Data["PaddedKeyID"] = asymkey_model.PaddedKeyID(keyID)
+ ctx.RenderWithErr(ctx.Tr("settings.gpg_invalid_token_signature"), tplSettingsKeys, &form)
+ case asymkey_model.IsErrGPGNoEmailFound(err):
+ loadKeysData(ctx)
+
+ ctx.Data["Err_Content"] = true
+ ctx.Data["Err_Signature"] = true
+ keyID := err.(asymkey_model.ErrGPGNoEmailFound).ID
+ ctx.Data["KeyID"] = keyID
+ ctx.Data["PaddedKeyID"] = asymkey_model.PaddedKeyID(keyID)
+ 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 "verify_gpg":
+ token := asymkey_model.VerificationToken(ctx.Doer, 1)
+ lastToken := asymkey_model.VerificationToken(ctx.Doer, 0)
+
+ keyID, err := asymkey_model.VerifyGPGKey(ctx, ctx.Doer.ID, form.KeyID, token, form.Signature)
+ if err != nil && asymkey_model.IsErrGPGInvalidTokenSignature(err) {
+ keyID, err = asymkey_model.VerifyGPGKey(ctx, ctx.Doer.ID, form.KeyID, lastToken, form.Signature)
+ }
+ if err != nil {
+ ctx.Data["HasGPGVerifyError"] = true
+ switch {
+ case asymkey_model.IsErrGPGInvalidTokenSignature(err):
+ loadKeysData(ctx)
+ ctx.Data["VerifyingID"] = form.KeyID
+ ctx.Data["Err_Signature"] = true
+ keyID := err.(asymkey_model.ErrGPGInvalidTokenSignature).ID
+ ctx.Data["KeyID"] = keyID
+ ctx.Data["PaddedKeyID"] = asymkey_model.PaddedKeyID(keyID)
+ ctx.RenderWithErr(ctx.Tr("settings.gpg_invalid_token_signature"), tplSettingsKeys, &form)
+ default:
+ ctx.ServerError("VerifyGPG", err)
+ }
+ }
+ ctx.Flash.Success(ctx.Tr("settings.verify_gpg_key_success", keyID))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/keys")
+ case "ssh":
+ if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageSSHKeys) {
+ ctx.NotFound("Not Found", fmt.Errorf("ssh keys setting is not allowed to be visited"))
+ return
+ }
+
+ content, err := asymkey_model.CheckPublicKeyString(form.Content)
+ if err != nil {
+ if db.IsErrSSHDisabled(err) {
+ ctx.Flash.Info(ctx.Tr("settings.ssh_disabled"))
+ } else if asymkey_model.IsErrKeyUnableVerify(err) {
+ ctx.Flash.Info(ctx.Tr("form.unable_verify_ssh_key"))
+ } else if err == asymkey_model.ErrKeyIsPrivate {
+ ctx.Flash.Error(ctx.Tr("form.must_use_public_key"))
+ } else {
+ ctx.Flash.Error(ctx.Tr("form.invalid_ssh_key", err.Error()))
+ }
+ ctx.Redirect(setting.AppSubURL + "/user/settings/keys")
+ return
+ }
+
+ if _, err = asymkey_model.AddPublicKey(ctx, ctx.Doer.ID, form.Title, content, 0); err != nil {
+ ctx.Data["HasSSHError"] = true
+ switch {
+ case asymkey_model.IsErrKeyAlreadyExist(err):
+ loadKeysData(ctx)
+
+ ctx.Data["Err_Content"] = true
+ ctx.RenderWithErr(ctx.Tr("settings.ssh_key_been_used"), tplSettingsKeys, &form)
+ case asymkey_model.IsErrKeyNameAlreadyUsed(err):
+ loadKeysData(ctx)
+
+ ctx.Data["Err_Title"] = true
+ ctx.RenderWithErr(ctx.Tr("settings.ssh_key_name_used"), tplSettingsKeys, &form)
+ case asymkey_model.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")
+ case "verify_ssh":
+ if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageSSHKeys) {
+ ctx.NotFound("Not Found", fmt.Errorf("ssh keys setting is not allowed to be visited"))
+ return
+ }
+
+ token := asymkey_model.VerificationToken(ctx.Doer, 1)
+ lastToken := asymkey_model.VerificationToken(ctx.Doer, 0)
+
+ fingerprint, err := asymkey_model.VerifySSHKey(ctx, ctx.Doer.ID, form.Fingerprint, token, form.Signature)
+ if err != nil && asymkey_model.IsErrSSHInvalidTokenSignature(err) {
+ fingerprint, err = asymkey_model.VerifySSHKey(ctx, ctx.Doer.ID, form.Fingerprint, lastToken, form.Signature)
+ }
+ if err != nil {
+ ctx.Data["HasSSHVerifyError"] = true
+ switch {
+ case asymkey_model.IsErrSSHInvalidTokenSignature(err):
+ loadKeysData(ctx)
+ ctx.Data["Err_Signature"] = true
+ ctx.Data["Fingerprint"] = err.(asymkey_model.ErrSSHInvalidTokenSignature).Fingerprint
+ ctx.RenderWithErr(ctx.Tr("settings.ssh_invalid_token_signature"), tplSettingsKeys, &form)
+ default:
+ ctx.ServerError("VerifySSH", err)
+ }
+ }
+ ctx.Flash.Success(ctx.Tr("settings.verify_ssh_key_success", fingerprint))
+ 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.FormString("type") {
+ case "gpg":
+ if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageGPGKeys) {
+ ctx.NotFound("Not Found", fmt.Errorf("gpg keys setting is not allowed to be visited"))
+ return
+ }
+ if err := asymkey_model.DeleteGPGKey(ctx, ctx.Doer, ctx.FormInt64("id")); err != nil {
+ ctx.Flash.Error("DeleteGPGKey: " + err.Error())
+ } else {
+ ctx.Flash.Success(ctx.Tr("settings.gpg_key_deletion_success"))
+ }
+ case "ssh":
+ if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageSSHKeys) {
+ ctx.NotFound("Not Found", fmt.Errorf("ssh keys setting is not allowed to be visited"))
+ return
+ }
+
+ keyID := ctx.FormInt64("id")
+ external, err := asymkey_model.PublicKeyIsExternallyManaged(ctx, keyID)
+ if err != nil {
+ ctx.ServerError("sshKeysExternalManaged", err)
+ return
+ }
+ if external {
+ ctx.Flash.Error(ctx.Tr("settings.ssh_externally_managed"))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/keys")
+ return
+ }
+ if err := asymkey_service.DeletePublicKey(ctx, ctx.Doer, keyID); err != nil {
+ ctx.Flash.Error("DeletePublicKey: " + err.Error())
+ } else {
+ ctx.Flash.Success(ctx.Tr("settings.ssh_key_deletion_success"))
+ }
+ case "principal":
+ if err := asymkey_service.DeletePublicKey(ctx, ctx.Doer, ctx.FormInt64("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.JSONRedirect(setting.AppSubURL + "/user/settings/keys")
+}
+
+func loadKeysData(ctx *context.Context) {
+ keys, err := db.Find[asymkey_model.PublicKey](ctx, asymkey_model.FindPublicKeyOptions{
+ OwnerID: ctx.Doer.ID,
+ NotKeytype: asymkey_model.KeyTypePrincipal,
+ })
+ if err != nil {
+ ctx.ServerError("ListPublicKeys", err)
+ return
+ }
+ ctx.Data["Keys"] = keys
+
+ externalKeys, err := asymkey_model.PublicKeysAreExternallyManaged(ctx, keys)
+ if err != nil {
+ ctx.ServerError("ListPublicKeys", err)
+ return
+ }
+ ctx.Data["ExternalKeys"] = externalKeys
+
+ gpgkeys, err := db.Find[asymkey_model.GPGKey](ctx, asymkey_model.FindGPGKeyOptions{
+ ListOptions: db.ListOptionsAll,
+ OwnerID: ctx.Doer.ID,
+ })
+ if err != nil {
+ ctx.ServerError("ListGPGKeys", err)
+ return
+ }
+ if err := asymkey_model.GPGKeyList(gpgkeys).LoadSubKeys(ctx); err != nil {
+ ctx.ServerError("LoadSubKeys", err)
+ return
+ }
+ ctx.Data["GPGKeys"] = gpgkeys
+ tokenToSign := asymkey_model.VerificationToken(ctx.Doer, 1)
+
+ // generate a new aes cipher using the csrfToken
+ ctx.Data["TokenToSign"] = tokenToSign
+
+ principals, err := db.Find[asymkey_model.PublicKey](ctx, asymkey_model.FindPublicKeyOptions{
+ ListOptions: db.ListOptionsAll,
+ OwnerID: ctx.Doer.ID,
+ KeyTypes: []asymkey_model.KeyType{asymkey_model.KeyTypePrincipal},
+ })
+ if err != nil {
+ ctx.ServerError("ListPrincipalKeys", err)
+ return
+ }
+ ctx.Data["Principals"] = principals
+
+ ctx.Data["VerifyingID"] = ctx.FormString("verify_gpg")
+ ctx.Data["VerifyingFingerprint"] = ctx.FormString("verify_ssh")
+ ctx.Data["UserDisabledFeatures"] = user_model.DisabledFeaturesWithLoginType(ctx.Doer)
+}
diff --git a/routers/web/user/setting/main_test.go b/routers/web/user/setting/main_test.go
new file mode 100644
index 0000000..e398208
--- /dev/null
+++ b/routers/web/user/setting/main_test.go
@@ -0,0 +1,14 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/models/unittest"
+)
+
+func TestMain(m *testing.M) {
+ unittest.MainTest(m)
+}
diff --git a/routers/web/user/setting/oauth2.go b/routers/web/user/setting/oauth2.go
new file mode 100644
index 0000000..1f485e0
--- /dev/null
+++ b/routers/web/user/setting/oauth2.go
@@ -0,0 +1,68 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/services/context"
+)
+
+const (
+ tplSettingsOAuthApplicationEdit base.TplName = "user/settings/applications_oauth2_edit"
+)
+
+func newOAuth2CommonHandlers(userID int64) *OAuth2CommonHandlers {
+ return &OAuth2CommonHandlers{
+ OwnerID: userID,
+ BasePathList: setting.AppSubURL + "/user/settings/applications",
+ BasePathEditPrefix: setting.AppSubURL + "/user/settings/applications/oauth2",
+ TplAppEdit: tplSettingsOAuthApplicationEdit,
+ }
+}
+
+// OAuthApplicationsPost response for adding a oauth2 application
+func OAuthApplicationsPost(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("settings")
+ ctx.Data["PageIsSettingsApplications"] = true
+
+ oa := newOAuth2CommonHandlers(ctx.Doer.ID)
+ oa.AddApp(ctx)
+}
+
+// OAuthApplicationsEdit response for editing oauth2 application
+func OAuthApplicationsEdit(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("settings")
+ ctx.Data["PageIsSettingsApplications"] = true
+
+ oa := newOAuth2CommonHandlers(ctx.Doer.ID)
+ oa.EditSave(ctx)
+}
+
+// OAuthApplicationsRegenerateSecret handles the post request for regenerating the secret
+func OAuthApplicationsRegenerateSecret(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("settings")
+ ctx.Data["PageIsSettingsApplications"] = true
+
+ oa := newOAuth2CommonHandlers(ctx.Doer.ID)
+ oa.RegenerateSecret(ctx)
+}
+
+// OAuth2ApplicationShow displays the given application
+func OAuth2ApplicationShow(ctx *context.Context) {
+ oa := newOAuth2CommonHandlers(ctx.Doer.ID)
+ oa.EditShow(ctx)
+}
+
+// DeleteOAuth2Application deletes the given oauth2 application
+func DeleteOAuth2Application(ctx *context.Context) {
+ oa := newOAuth2CommonHandlers(ctx.Doer.ID)
+ oa.DeleteApp(ctx)
+}
+
+// RevokeOAuth2Grant revokes the grant with the given id
+func RevokeOAuth2Grant(ctx *context.Context) {
+ oa := newOAuth2CommonHandlers(ctx.Doer.ID)
+ oa.RevokeGrant(ctx)
+}
diff --git a/routers/web/user/setting/oauth2_common.go b/routers/web/user/setting/oauth2_common.go
new file mode 100644
index 0000000..85d1e82
--- /dev/null
+++ b/routers/web/user/setting/oauth2_common.go
@@ -0,0 +1,163 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "fmt"
+ "net/http"
+
+ "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/modules/web"
+ shared_user "code.gitea.io/gitea/routers/web/shared/user"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/forms"
+)
+
+type OAuth2CommonHandlers struct {
+ OwnerID int64 // 0 for instance-wide, otherwise OrgID or UserID
+ BasePathList string // the base URL for the application list page, eg: "/user/setting/applications"
+ BasePathEditPrefix string // the base URL for the application edit page, will be appended with app id, eg: "/user/setting/applications/oauth2"
+ TplAppEdit base.TplName // the template for the application edit page
+}
+
+func (oa *OAuth2CommonHandlers) renderEditPage(ctx *context.Context) {
+ app := ctx.Data["App"].(*auth.OAuth2Application)
+ ctx.Data["FormActionPath"] = fmt.Sprintf("%s/%d", oa.BasePathEditPrefix, app.ID)
+
+ if ctx.ContextUser != nil && ctx.ContextUser.IsOrganization() {
+ if err := shared_user.LoadHeaderCount(ctx); err != nil {
+ ctx.ServerError("LoadHeaderCount", err)
+ return
+ }
+ }
+
+ ctx.HTML(http.StatusOK, oa.TplAppEdit)
+}
+
+// AddApp adds an oauth2 application
+func (oa *OAuth2CommonHandlers) AddApp(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.EditOAuth2ApplicationForm)
+ if ctx.HasError() {
+ ctx.Flash.Error(ctx.GetErrMsg())
+ // go to the application list page
+ ctx.Redirect(oa.BasePathList)
+ return
+ }
+
+ // TODO validate redirect URI
+ app, err := auth.CreateOAuth2Application(ctx, auth.CreateOAuth2ApplicationOptions{
+ Name: form.Name,
+ RedirectURIs: util.SplitTrimSpace(form.RedirectURIs, "\n"),
+ UserID: oa.OwnerID,
+ ConfidentialClient: form.ConfidentialClient,
+ })
+ if err != nil {
+ ctx.ServerError("CreateOAuth2Application", err)
+ return
+ }
+
+ // render the edit page with secret
+ ctx.Flash.Success(ctx.Tr("settings.create_oauth2_application_success"), true)
+ ctx.Data["App"] = app
+ ctx.Data["ClientSecret"], err = app.GenerateClientSecret(ctx)
+ if err != nil {
+ ctx.ServerError("GenerateClientSecret", err)
+ return
+ }
+
+ oa.renderEditPage(ctx)
+}
+
+// EditShow displays the given application
+func (oa *OAuth2CommonHandlers) EditShow(ctx *context.Context) {
+ app, err := auth.GetOAuth2ApplicationByID(ctx, ctx.ParamsInt64("id"))
+ if err != nil {
+ if auth.IsErrOAuthApplicationNotFound(err) {
+ ctx.NotFound("Application not found", err)
+ return
+ }
+ ctx.ServerError("GetOAuth2ApplicationByID", err)
+ return
+ }
+ if app.UID != oa.OwnerID {
+ ctx.NotFound("Application not found", nil)
+ return
+ }
+ ctx.Data["App"] = app
+ oa.renderEditPage(ctx)
+}
+
+// EditSave saves the oauth2 application
+func (oa *OAuth2CommonHandlers) EditSave(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.EditOAuth2ApplicationForm)
+
+ if ctx.HasError() {
+ oa.renderEditPage(ctx)
+ return
+ }
+
+ // TODO validate redirect URI
+ var err error
+ if ctx.Data["App"], err = auth.UpdateOAuth2Application(ctx, auth.UpdateOAuth2ApplicationOptions{
+ ID: ctx.ParamsInt64("id"),
+ Name: form.Name,
+ RedirectURIs: util.SplitTrimSpace(form.RedirectURIs, "\n"),
+ UserID: oa.OwnerID,
+ ConfidentialClient: form.ConfidentialClient,
+ }); err != nil {
+ ctx.ServerError("UpdateOAuth2Application", err)
+ return
+ }
+ ctx.Flash.Success(ctx.Tr("settings.update_oauth2_application_success"))
+ ctx.Redirect(oa.BasePathList)
+}
+
+// RegenerateSecret regenerates the secret
+func (oa *OAuth2CommonHandlers) RegenerateSecret(ctx *context.Context) {
+ app, err := auth.GetOAuth2ApplicationByID(ctx, ctx.ParamsInt64("id"))
+ if err != nil {
+ if auth.IsErrOAuthApplicationNotFound(err) {
+ ctx.NotFound("Application not found", err)
+ return
+ }
+ ctx.ServerError("GetOAuth2ApplicationByID", err)
+ return
+ }
+ if app.UID != oa.OwnerID {
+ ctx.NotFound("Application not found", nil)
+ return
+ }
+ ctx.Data["App"] = app
+ ctx.Data["ClientSecret"], err = app.GenerateClientSecret(ctx)
+ if err != nil {
+ ctx.ServerError("GenerateClientSecret", err)
+ return
+ }
+ ctx.Flash.Success(ctx.Tr("settings.update_oauth2_application_success"), true)
+ oa.renderEditPage(ctx)
+}
+
+// DeleteApp deletes the given oauth2 application
+func (oa *OAuth2CommonHandlers) DeleteApp(ctx *context.Context) {
+ if err := auth.DeleteOAuth2Application(ctx, ctx.ParamsInt64("id"), oa.OwnerID); err != nil {
+ ctx.ServerError("DeleteOAuth2Application", err)
+ return
+ }
+
+ ctx.Flash.Success(ctx.Tr("settings.remove_oauth2_application_success"))
+ ctx.JSONRedirect(oa.BasePathList)
+}
+
+// RevokeGrant revokes the grant
+func (oa *OAuth2CommonHandlers) RevokeGrant(ctx *context.Context) {
+ if err := auth.RevokeOAuth2Grant(ctx, ctx.ParamsInt64("grantId"), oa.OwnerID); err != nil {
+ ctx.ServerError("RevokeOAuth2Grant", err)
+ return
+ }
+
+ ctx.Flash.Success(ctx.Tr("settings.revoke_oauth2_grant_success"))
+ ctx.JSONRedirect(oa.BasePathList)
+}
diff --git a/routers/web/user/setting/packages.go b/routers/web/user/setting/packages.go
new file mode 100644
index 0000000..4132659
--- /dev/null
+++ b/routers/web/user/setting/packages.go
@@ -0,0 +1,119 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "net/http"
+ "strings"
+
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/base"
+ chef_module "code.gitea.io/gitea/modules/packages/chef"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/util"
+ shared "code.gitea.io/gitea/routers/web/shared/packages"
+ "code.gitea.io/gitea/services/context"
+)
+
+const (
+ tplSettingsPackages base.TplName = "user/settings/packages"
+ tplSettingsPackagesRuleEdit base.TplName = "user/settings/packages_cleanup_rules_edit"
+ tplSettingsPackagesRulePreview base.TplName = "user/settings/packages_cleanup_rules_preview"
+)
+
+func Packages(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("packages.title")
+ ctx.Data["PageIsSettingsPackages"] = true
+
+ shared.SetPackagesContext(ctx, ctx.Doer)
+
+ ctx.HTML(http.StatusOK, tplSettingsPackages)
+}
+
+func PackagesRuleAdd(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("packages.title")
+ ctx.Data["PageIsSettingsPackages"] = true
+
+ shared.SetRuleAddContext(ctx)
+
+ ctx.HTML(http.StatusOK, tplSettingsPackagesRuleEdit)
+}
+
+func PackagesRuleEdit(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("packages.title")
+ ctx.Data["PageIsSettingsPackages"] = true
+
+ shared.SetRuleEditContext(ctx, ctx.Doer)
+
+ ctx.HTML(http.StatusOK, tplSettingsPackagesRuleEdit)
+}
+
+func PackagesRuleAddPost(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("settings")
+ ctx.Data["PageIsSettingsPackages"] = true
+
+ shared.PerformRuleAddPost(
+ ctx,
+ ctx.Doer,
+ setting.AppSubURL+"/user/settings/packages",
+ tplSettingsPackagesRuleEdit,
+ )
+}
+
+func PackagesRuleEditPost(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("packages.title")
+ ctx.Data["PageIsSettingsPackages"] = true
+
+ shared.PerformRuleEditPost(
+ ctx,
+ ctx.Doer,
+ setting.AppSubURL+"/user/settings/packages",
+ tplSettingsPackagesRuleEdit,
+ )
+}
+
+func PackagesRulePreview(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("packages.title")
+ ctx.Data["PageIsSettingsPackages"] = true
+
+ shared.SetRulePreviewContext(ctx, ctx.Doer)
+
+ ctx.HTML(http.StatusOK, tplSettingsPackagesRulePreview)
+}
+
+func InitializeCargoIndex(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("packages.title")
+ ctx.Data["PageIsSettingsPackages"] = true
+
+ shared.InitializeCargoIndex(ctx, ctx.Doer)
+
+ ctx.Redirect(setting.AppSubURL + "/user/settings/packages")
+}
+
+func RebuildCargoIndex(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("packages.title")
+ ctx.Data["PageIsSettingsPackages"] = true
+
+ shared.RebuildCargoIndex(ctx, ctx.Doer)
+
+ ctx.Redirect(setting.AppSubURL + "/user/settings/packages")
+}
+
+func RegenerateChefKeyPair(ctx *context.Context) {
+ priv, pub, err := util.GenerateKeyPair(chef_module.KeyBits)
+ if err != nil {
+ ctx.ServerError("GenerateKeyPair", err)
+ return
+ }
+
+ if err := user_model.SetUserSetting(ctx, ctx.Doer.ID, chef_module.SettingPublicPem, pub); err != nil {
+ ctx.ServerError("SetUserSetting", err)
+ return
+ }
+
+ ctx.ServeContent(strings.NewReader(priv), &context.ServeHeaderOptions{
+ ContentType: "application/x-pem-file",
+ Filename: ctx.Doer.Name + ".priv",
+ })
+}
diff --git a/routers/web/user/setting/profile.go b/routers/web/user/setting/profile.go
new file mode 100644
index 0000000..907f0f5
--- /dev/null
+++ b/routers/web/user/setting/profile.go
@@ -0,0 +1,433 @@
+// Copyright 2014 The Gogs Authors. All rights reserved.
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "math/big"
+ "net/http"
+ "os"
+ "path/filepath"
+ "slices"
+ "strings"
+
+ "code.gitea.io/gitea/models/avatars"
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/organization"
+ repo_model "code.gitea.io/gitea/models/repo"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/optional"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/translation"
+ "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/context"
+ "code.gitea.io/gitea/services/forms"
+ user_service "code.gitea.io/gitea/services/user"
+)
+
+const (
+ tplSettingsProfile base.TplName = "user/settings/profile"
+ tplSettingsAppearance base.TplName = "user/settings/appearance"
+ tplSettingsOrganization base.TplName = "user/settings/organization"
+ tplSettingsRepositories base.TplName = "user/settings/repos"
+)
+
+// must be kept in sync with `web_src/js/features/user-settings.js`
+var recognisedPronouns = []string{"", "he/him", "she/her", "they/them", "it/its", "any pronouns"}
+
+// Profile render user's profile page
+func Profile(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("settings.profile")
+ ctx.Data["PageIsSettingsProfile"] = true
+ ctx.Data["AllowedUserVisibilityModes"] = setting.Service.AllowedUserVisibilityModesSlice.ToVisibleTypeSlice()
+ ctx.Data["DisableGravatar"] = setting.Config().Picture.DisableGravatar.Value(ctx)
+ ctx.Data["PronounsAreCustom"] = !slices.Contains(recognisedPronouns, ctx.Doer.Pronouns)
+
+ ctx.HTML(http.StatusOK, tplSettingsProfile)
+}
+
+// ProfilePost response for change user's profile
+func ProfilePost(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("settings")
+ ctx.Data["PageIsSettingsProfile"] = true
+ ctx.Data["AllowedUserVisibilityModes"] = setting.Service.AllowedUserVisibilityModesSlice.ToVisibleTypeSlice()
+ ctx.Data["DisableGravatar"] = setting.Config().Picture.DisableGravatar.Value(ctx)
+ ctx.Data["PronounsAreCustom"] = !slices.Contains(recognisedPronouns, ctx.Doer.Pronouns)
+
+ if ctx.HasError() {
+ ctx.HTML(http.StatusOK, tplSettingsProfile)
+ return
+ }
+
+ form := web.GetForm(ctx).(*forms.UpdateProfileForm)
+
+ if form.Name != "" {
+ if err := user_service.RenameUser(ctx, ctx.Doer, form.Name); err != nil {
+ switch {
+ case user_model.IsErrUserIsNotLocal(err):
+ ctx.Flash.Error(ctx.Tr("form.username_change_not_local_user"))
+ case user_model.IsErrUserAlreadyExist(err):
+ ctx.Flash.Error(ctx.Tr("form.username_been_taken"))
+ case db.IsErrNameReserved(err):
+ ctx.Flash.Error(ctx.Tr("user.form.name_reserved", form.Name))
+ case db.IsErrNamePatternNotAllowed(err):
+ ctx.Flash.Error(ctx.Tr("user.form.name_pattern_not_allowed", form.Name))
+ case db.IsErrNameCharsNotAllowed(err):
+ ctx.Flash.Error(ctx.Tr("user.form.name_chars_not_allowed", form.Name))
+ default:
+ ctx.ServerError("RenameUser", err)
+ return
+ }
+ ctx.Redirect(setting.AppSubURL + "/user/settings")
+ return
+ }
+ }
+
+ opts := &user_service.UpdateOptions{
+ FullName: optional.Some(form.FullName),
+ KeepEmailPrivate: optional.Some(form.KeepEmailPrivate),
+ Description: optional.Some(form.Biography),
+ Pronouns: optional.Some(form.Pronouns),
+ Website: optional.Some(form.Website),
+ Location: optional.Some(form.Location),
+ Visibility: optional.Some(form.Visibility),
+ KeepActivityPrivate: optional.Some(form.KeepActivityPrivate),
+ }
+ if err := user_service.UpdateUser(ctx, ctx.Doer, opts); err != nil {
+ ctx.ServerError("UpdateUser", err)
+ return
+ }
+
+ log.Trace("User settings updated: %s", ctx.Doer.Name)
+ ctx.Flash.Success(ctx.Tr("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 *user_model.User) error {
+ ctxUser.UseCustomAvatar = form.Source == forms.AvatarLocal
+ if len(form.Gravatar) > 0 {
+ if form.Avatar != nil {
+ ctxUser.Avatar = avatars.HashEmail(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: %w", err)
+ }
+ defer fr.Close()
+
+ if form.Avatar.Size > setting.Avatar.MaxFileSize {
+ return errors.New(ctx.Locale.TrString("settings.uploaded_avatar_is_too_big", form.Avatar.Size/1024, setting.Avatar.MaxFileSize/1024))
+ }
+
+ data, err := io.ReadAll(fr)
+ if err != nil {
+ return fmt.Errorf("io.ReadAll: %w", err)
+ }
+
+ st := typesniffer.DetectContentType(data)
+ if !(st.IsImage() && !st.IsSvgImage()) {
+ return errors.New(ctx.Locale.TrString("settings.uploaded_avatar_not_a_image"))
+ }
+ if err = user_service.UploadAvatar(ctx, ctxUser, data); err != nil {
+ return fmt.Errorf("UploadAvatar: %w", 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 := user_model.GenerateRandomAvatar(ctx, ctxUser); err != nil {
+ log.Error("GenerateRandomAvatar[%d]: %v", ctxUser.ID, err)
+ }
+ }
+
+ if err := user_model.UpdateUserCols(ctx, ctxUser, "avatar", "avatar_email", "use_custom_avatar"); err != nil {
+ return fmt.Errorf("UpdateUserCols: %w", 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.Doer); 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 := user_service.DeleteAvatar(ctx, ctx.Doer); err != nil {
+ ctx.Flash.Error(err.Error())
+ }
+
+ ctx.JSONRedirect(setting.AppSubURL + "/user/settings")
+}
+
+// Organization render all the organization of the user
+func Organization(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("settings.organization")
+ ctx.Data["PageIsSettingsOrganization"] = true
+
+ opts := organization.FindOrgOptions{
+ ListOptions: db.ListOptions{
+ PageSize: setting.UI.Admin.UserPagingNum,
+ Page: ctx.FormInt("page"),
+ },
+ UserID: ctx.Doer.ID,
+ IncludePrivate: ctx.IsSigned,
+ }
+
+ if opts.Page <= 0 {
+ opts.Page = 1
+ }
+
+ orgs, total, err := db.FindAndCount[organization.Organization](ctx, opts)
+ if err != nil {
+ ctx.ServerError("FindOrgs", err)
+ return
+ }
+
+ ctx.Data["Orgs"] = orgs
+ pager := context.NewPagination(int(total), opts.PageSize, opts.Page, 5)
+ pager.SetDefaultParams(ctx)
+ ctx.Data["Page"] = pager
+ 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.repos")
+ ctx.Data["PageIsSettingsRepos"] = true
+ ctx.Data["allowAdopt"] = ctx.IsUserSiteAdmin() || setting.Repository.AllowAdoptionOfUnadoptedRepositories
+ ctx.Data["allowDelete"] = ctx.IsUserSiteAdmin() || setting.Repository.AllowDeleteOfUnadoptedRepositories
+
+ opts := db.ListOptions{
+ PageSize: setting.UI.Admin.UserPagingNum,
+ Page: ctx.FormInt("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.Doer
+ count := 0
+
+ if adoptOrDelete {
+ repoNames := make([]string, 0, setting.UI.Admin.UserPagingNum)
+ repos := map[string]*repo_model.Repository{}
+ // We're going to iterate by pagesize.
+ root := user_model.UserPath(ctxUser.Name)
+ if err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil
+ }
+ return err
+ }
+ if !d.IsDir() || path == root {
+ return nil
+ }
+ name := d.Name()
+ if !strings.HasSuffix(name, ".git") {
+ return filepath.SkipDir
+ }
+ name = name[:len(name)-4]
+ if repo_model.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.WalkDir", err)
+ return
+ }
+
+ userRepos, _, err := repo_model.GetUserRepositories(ctx, &repo_model.SearchRepoOptions{
+ Actor: ctxUser,
+ Private: true,
+ ListOptions: db.ListOptions{
+ Page: 1,
+ PageSize: setting.UI.Admin.UserPagingNum,
+ },
+ LowerNames: repoNames,
+ })
+ if err != nil {
+ ctx.ServerError("GetUserRepositories", err)
+ return
+ }
+ for _, repo := range userRepos {
+ if repo.IsFork {
+ if err := repo.GetBaseRepo(ctx); err != nil {
+ ctx.ServerError("GetBaseRepo", err)
+ return
+ }
+ }
+ repos[repo.LowerName] = repo
+ }
+ ctx.Data["Dirs"] = repoNames
+ ctx.Data["ReposMap"] = repos
+ } else {
+ repos, count64, err := repo_model.GetUserRepositories(ctx, &repo_model.SearchRepoOptions{Actor: ctxUser, Private: true, ListOptions: opts})
+ if err != nil {
+ ctx.ServerError("GetUserRepositories", err)
+ return
+ }
+ count = int(count64)
+
+ for i := range repos {
+ if repos[i].IsFork {
+ if err := repos[i].GetBaseRepo(ctx); err != nil {
+ ctx.ServerError("GetBaseRepo", err)
+ return
+ }
+ }
+ }
+
+ ctx.Data["Repos"] = repos
+ }
+ ctx.Data["ContextUser"] = ctxUser
+ pager := context.NewPagination(count, opts.PageSize, opts.Page, 5)
+ pager.SetDefaultParams(ctx)
+ ctx.Data["Page"] = pager
+ ctx.HTML(http.StatusOK, tplSettingsRepositories)
+}
+
+// Appearance render user's appearance settings
+func Appearance(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("settings.appearance")
+ ctx.Data["PageIsSettingsAppearance"] = true
+
+ var hiddenCommentTypes *big.Int
+ val, err := user_model.GetUserSetting(ctx, ctx.Doer.ID, user_model.SettingsKeyHiddenCommentTypes)
+ if err != nil {
+ ctx.ServerError("GetUserSetting", err)
+ return
+ }
+ hiddenCommentTypes, _ = new(big.Int).SetString(val, 10) // we can safely ignore the failed conversion here
+
+ ctx.Data["IsCommentTypeGroupChecked"] = func(commentTypeGroup string) bool {
+ return forms.IsUserHiddenCommentTypeGroupChecked(commentTypeGroup, hiddenCommentTypes)
+ }
+
+ ctx.HTML(http.StatusOK, tplSettingsAppearance)
+}
+
+// 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["PageIsSettingsAppearance"] = true
+
+ if ctx.HasError() {
+ ctx.Redirect(setting.AppSubURL + "/user/settings/appearance")
+ return
+ }
+
+ if !form.IsThemeExists() {
+ ctx.Flash.Error(ctx.Tr("settings.theme_update_error"))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/appearance")
+ return
+ }
+
+ opts := &user_service.UpdateOptions{
+ Theme: optional.Some(form.Theme),
+ }
+ if err := user_service.UpdateUser(ctx, ctx.Doer, opts); err != nil {
+ ctx.Flash.Error(ctx.Tr("settings.theme_update_error"))
+ } else {
+ ctx.Flash.Success(ctx.Tr("settings.theme_update_success"))
+ }
+
+ ctx.Redirect(setting.AppSubURL + "/user/settings/appearance")
+}
+
+// UpdateUserLang update a user's language
+func UpdateUserLang(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.UpdateLanguageForm)
+ ctx.Data["Title"] = ctx.Tr("settings")
+ ctx.Data["PageIsSettingsAppearance"] = true
+
+ if form.Language != "" {
+ if !util.SliceContainsString(setting.Langs, form.Language) {
+ ctx.Flash.Error(ctx.Tr("settings.update_language_not_found", form.Language))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/appearance")
+ return
+ }
+ }
+
+ opts := &user_service.UpdateOptions{
+ Language: optional.Some(form.Language),
+ }
+ if err := user_service.UpdateUser(ctx, ctx.Doer, opts); err != nil {
+ ctx.ServerError("UpdateUser", err)
+ return
+ }
+
+ // Update the language to the one we just set
+ middleware.SetLocaleCookie(ctx.Resp, ctx.Doer.Language, 0)
+
+ log.Trace("User settings updated: %s", ctx.Doer.Name)
+ ctx.Flash.Success(translation.NewLocale(ctx.Doer.Language).TrString("settings.update_language_success"))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/appearance")
+}
+
+// UpdateUserHints updates a user's hints settings
+func UpdateUserHints(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.UpdateHintsForm)
+ ctx.Data["Title"] = ctx.Tr("settings")
+ ctx.Data["PageIsSettingsAppearance"] = true
+
+ opts := &user_service.UpdateOptions{
+ EnableRepoUnitHints: optional.Some(form.EnableRepoUnitHints),
+ }
+ if err := user_service.UpdateUser(ctx, ctx.Doer, opts); err != nil {
+ ctx.ServerError("UpdateUser", err)
+ return
+ }
+
+ log.Trace("User settings updated: %s", ctx.Doer.Name)
+ ctx.Flash.Success(translation.NewLocale(ctx.Doer.Language).TrString("settings.update_hints_success"))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/appearance")
+}
+
+// UpdateUserHiddenComments update a user's shown comment types
+func UpdateUserHiddenComments(ctx *context.Context) {
+ err := user_model.SetUserSetting(ctx, ctx.Doer.ID, user_model.SettingsKeyHiddenCommentTypes, forms.UserHiddenCommentTypesFromRequest(ctx).String())
+ if err != nil {
+ ctx.ServerError("SetUserSetting", err)
+ return
+ }
+
+ log.Trace("User settings updated: %s", ctx.Doer.Name)
+ ctx.Flash.Success(ctx.Tr("settings.saved_successfully"))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/appearance")
+}
diff --git a/routers/web/user/setting/runner.go b/routers/web/user/setting/runner.go
new file mode 100644
index 0000000..2bb10cc
--- /dev/null
+++ b/routers/web/user/setting/runner.go
@@ -0,0 +1,13 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/services/context"
+)
+
+func RedirectToDefaultSetting(ctx *context.Context) {
+ ctx.Redirect(setting.AppSubURL + "/user/settings/actions/runners")
+}
diff --git a/routers/web/user/setting/security/2fa.go b/routers/web/user/setting/security/2fa.go
new file mode 100644
index 0000000..a145867
--- /dev/null
+++ b/routers/web/user/setting/security/2fa.go
@@ -0,0 +1,260 @@
+// Copyright 2014 The Gogs Authors. All rights reserved.
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package security
+
+import (
+ "bytes"
+ "encoding/base64"
+ "html/template"
+ "image/png"
+ "net/http"
+ "strings"
+
+ "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/forms"
+ "code.gitea.io/gitea/services/mailer"
+
+ "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 := auth.GetTwoFactorByUID(ctx, ctx.Doer.ID)
+ if err != nil {
+ if auth.IsErrTwoFactorNotEnrolled(err) {
+ ctx.Flash.Error(ctx.Tr("settings.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 = auth.UpdateTwoFactor(ctx, 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 := auth.GetTwoFactorByUID(ctx, ctx.Doer.ID)
+ if err != nil {
+ if auth.IsErrTwoFactorNotEnrolled(err) {
+ ctx.Flash.Error(ctx.Tr("settings.twofa_not_enrolled"))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/security")
+ }
+ ctx.ServerError("SettingsTwoFactor: Failed to GetTwoFactorByUID", err)
+ return
+ }
+
+ if err = auth.DeleteTwoFactorByID(ctx, t.ID, ctx.Doer.ID); err != nil {
+ if auth.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
+ }
+
+ if err := mailer.SendDisabledTOTP(ctx, ctx.Doer); err != nil {
+ ctx.ServerError("SendDisabledTOTP", 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.Doer.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 := auth.GetTwoFactorByUID(ctx, ctx.Doer.ID)
+ if t != nil {
+ // already enrolled - we should redirect back!
+ log.Warn("Trying to re-enroll %-v in twofa when already enrolled", ctx.Doer)
+ ctx.Flash.Error(ctx.Tr("settings.twofa_is_enrolled"))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/security")
+ return
+ }
+ if err != nil && !auth.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 := auth.GetTwoFactorByUID(ctx, ctx.Doer.ID)
+ if t != nil {
+ // already enrolled
+ ctx.Flash.Error(ctx.Tr("settings.twofa_is_enrolled"))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/security")
+ return
+ }
+ if err != nil && !auth.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 = &auth.TwoFactor{
+ UID: ctx.Doer.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 := mailer.SendTOTPEnrolled(ctx, ctx.Doer); err != nil {
+ ctx.ServerError("SendTOTPEnrolled", err)
+ return
+ }
+
+ if err = auth.NewTwoFactor(ctx, 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/openid.go b/routers/web/user/setting/security/openid.go
new file mode 100644
index 0000000..8f788e1
--- /dev/null
+++ b/routers/web/user/setting/security/openid.go
@@ -0,0 +1,126 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package security
+
+import (
+ "net/http"
+
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/auth/openid"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/services/context"
+ "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 := user_model.GetUserOpenIDs(ctx, ctx.Doer.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 := &user_model.UserOpenID{UID: ctx.Doer.ID, URI: id}
+ if err = user_model.AddUserOpenID(ctx, oid); err != nil {
+ if user_model.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.Doer.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 := user_model.DeleteUserOpenID(ctx, &user_model.UserOpenID{ID: ctx.FormInt64("id"), UID: ctx.Doer.ID}); err != nil {
+ ctx.ServerError("DeleteUserOpenID", err)
+ return
+ }
+ log.Trace("OpenID address deleted: %s", ctx.Doer.Name)
+
+ ctx.Flash.Success(ctx.Tr("settings.openid_deletion_success"))
+ ctx.JSONRedirect(setting.AppSubURL + "/user/settings/security")
+}
+
+// ToggleOpenIDVisibility response for toggle visibility of user's openid
+func ToggleOpenIDVisibility(ctx *context.Context) {
+ if err := user_model.ToggleUserOpenIDVisibility(ctx, ctx.FormInt64("id")); err != nil {
+ ctx.ServerError("ToggleUserOpenIDVisibility", err)
+ return
+ }
+
+ ctx.Redirect(setting.AppSubURL + "/user/settings/security")
+}
diff --git a/routers/web/user/setting/security/security.go b/routers/web/user/setting/security/security.go
new file mode 100644
index 0000000..8d6859a
--- /dev/null
+++ b/routers/web/user/setting/security/security.go
@@ -0,0 +1,148 @@
+// Copyright 2014 The Gogs Authors. All rights reserved.
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package security
+
+import (
+ "net/http"
+ "sort"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/optional"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/services/auth/source/oauth2"
+ "code.gitea.io/gitea/services/context"
+)
+
+const (
+ tplSettingsSecurity base.TplName = "user/settings/security/security"
+ tplSettingsTwofaEnroll base.TplName = "user/settings/security/twofa_enroll"
+)
+
+// Security render change user's password page and 2FA
+func Security(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("settings.security")
+ ctx.Data["PageIsSettingsSecurity"] = true
+
+ if ctx.FormString("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.FormInt64("id")
+ if id <= 0 {
+ ctx.Flash.Error("Account link id is not given")
+ } else {
+ if _, err := user_model.RemoveAccountLink(ctx, ctx.Doer, id); err != nil {
+ ctx.Flash.Error("RemoveAccountLink: " + err.Error())
+ } else {
+ ctx.Flash.Success(ctx.Tr("settings.remove_account_link_success"))
+ }
+ }
+
+ ctx.JSONRedirect(setting.AppSubURL + "/user/settings/security")
+}
+
+func loadSecurityData(ctx *context.Context) {
+ enrolled, err := auth_model.HasTwoFactorByUID(ctx, ctx.Doer.ID)
+ if err != nil {
+ ctx.ServerError("SettingsTwoFactor", err)
+ return
+ }
+ ctx.Data["TOTPEnrolled"] = enrolled
+
+ credentials, err := auth_model.GetWebAuthnCredentialsByUID(ctx, ctx.Doer.ID)
+ if err != nil {
+ ctx.ServerError("GetWebAuthnCredentialsByUID", err)
+ return
+ }
+ ctx.Data["WebAuthnCredentials"] = credentials
+
+ tokens, err := db.Find[auth_model.AccessToken](ctx, auth_model.ListAccessTokensOptions{UserID: ctx.Doer.ID})
+ if err != nil {
+ ctx.ServerError("ListAccessTokens", err)
+ return
+ }
+ ctx.Data["Tokens"] = tokens
+
+ accountLinks, err := db.Find[user_model.ExternalLoginUser](ctx, user_model.FindExternalUserOptions{
+ UserID: ctx.Doer.ID,
+ OrderBy: "login_source_id DESC",
+ })
+ if err != nil {
+ ctx.ServerError("ListAccountLinks", err)
+ return
+ }
+
+ // map the provider display name with the AuthSource
+ sources := make(map[*auth_model.Source]string)
+ for _, externalAccount := range accountLinks {
+ if authSource, err := auth_model.GetSourceByID(ctx, externalAccount.LoginSourceID); err == nil {
+ var providerDisplayName string
+
+ type DisplayNamed interface {
+ DisplayName() string
+ }
+
+ type Named interface {
+ Name() string
+ }
+
+ if displayNamed, ok := authSource.Cfg.(DisplayNamed); ok {
+ providerDisplayName = displayNamed.DisplayName()
+ } else if named, ok := authSource.Cfg.(Named); ok {
+ providerDisplayName = named.Name()
+ } else {
+ providerDisplayName = authSource.Name
+ }
+ sources[authSource] = providerDisplayName
+ }
+ }
+ ctx.Data["AccountLinks"] = sources
+
+ authSources, err := db.Find[auth_model.Source](ctx, auth_model.FindSourcesOptions{
+ IsActive: optional.None[bool](),
+ LoginType: auth_model.OAuth2,
+ })
+ if err != nil {
+ ctx.ServerError("FindSources", err)
+ return
+ }
+
+ var orderedOAuth2Names []string
+ oauth2Providers := make(map[string]oauth2.Provider)
+ for _, source := range authSources {
+ provider, err := oauth2.CreateProviderFromSource(source)
+ if err != nil {
+ ctx.ServerError("CreateProviderFromSource", err)
+ return
+ }
+ oauth2Providers[source.Name] = provider
+ if source.IsActive {
+ orderedOAuth2Names = append(orderedOAuth2Names, source.Name)
+ }
+ }
+
+ sort.Strings(orderedOAuth2Names)
+
+ ctx.Data["OrderedOAuth2Names"] = orderedOAuth2Names
+ ctx.Data["OAuth2Providers"] = oauth2Providers
+
+ openid, err := user_model.GetUserOpenIDs(ctx, ctx.Doer.ID)
+ if err != nil {
+ ctx.ServerError("GetUserOpenIDs", err)
+ return
+ }
+ ctx.Data["OpenIDs"] = openid
+}
diff --git a/routers/web/user/setting/security/webauthn.go b/routers/web/user/setting/security/webauthn.go
new file mode 100644
index 0000000..bfbc06c
--- /dev/null
+++ b/routers/web/user/setting/security/webauthn.go
@@ -0,0 +1,137 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package security
+
+import (
+ "errors"
+ "net/http"
+ "strconv"
+ "time"
+
+ "code.gitea.io/gitea/models/auth"
+ wa "code.gitea.io/gitea/modules/auth/webauthn"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/forms"
+ "code.gitea.io/gitea/services/mailer"
+
+ "github.com/go-webauthn/webauthn/protocol"
+ "github.com/go-webauthn/webauthn/webauthn"
+)
+
+// WebAuthnRegister initializes the webauthn registration procedure
+func WebAuthnRegister(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.WebauthnRegistrationForm)
+ if form.Name == "" {
+ // Set name to the hexadecimal of the current time
+ form.Name = strconv.FormatInt(time.Now().UnixNano(), 16)
+ }
+
+ cred, err := auth.GetWebAuthnCredentialByName(ctx, ctx.Doer.ID, form.Name)
+ if err != nil && !auth.IsErrWebAuthnCredentialNotExist(err) {
+ ctx.ServerError("GetWebAuthnCredentialsByUID", err)
+ return
+ }
+ if cred != nil {
+ ctx.Error(http.StatusConflict, "Name already taken")
+ return
+ }
+
+ _ = ctx.Session.Delete("webauthnRegistration")
+ if err := ctx.Session.Set("webauthnName", form.Name); err != nil {
+ ctx.ServerError("Unable to set session key for webauthnName", err)
+ return
+ }
+
+ credentialOptions, sessionData, err := wa.WebAuthn.BeginRegistration((*wa.User)(ctx.Doer))
+ if err != nil {
+ ctx.ServerError("Unable to BeginRegistration", err)
+ return
+ }
+
+ // Save the session data as marshaled JSON
+ if err = ctx.Session.Set("webauthnRegistration", sessionData); err != nil {
+ ctx.ServerError("Unable to set session", err)
+ return
+ }
+
+ ctx.JSON(http.StatusOK, credentialOptions)
+}
+
+// WebauthnRegisterPost receives the response of the security key
+func WebauthnRegisterPost(ctx *context.Context) {
+ name, ok := ctx.Session.Get("webauthnName").(string)
+ if !ok || name == "" {
+ ctx.ServerError("Get webauthnName", errors.New("no webauthnName"))
+ return
+ }
+
+ // Load the session data
+ sessionData, ok := ctx.Session.Get("webauthnRegistration").(*webauthn.SessionData)
+ if !ok || sessionData == nil {
+ ctx.ServerError("Get registration", errors.New("no registration"))
+ return
+ }
+ defer func() {
+ _ = ctx.Session.Delete("webauthnRegistration")
+ }()
+
+ // Verify that the challenge succeeded
+ cred, err := wa.WebAuthn.FinishRegistration((*wa.User)(ctx.Doer), *sessionData, ctx.Req)
+ if err != nil {
+ if pErr, ok := err.(*protocol.Error); ok {
+ log.Error("Unable to finish registration due to error: %v\nDevInfo: %s", pErr, pErr.DevInfo)
+ }
+ ctx.ServerError("CreateCredential", err)
+ return
+ }
+
+ dbCred, err := auth.GetWebAuthnCredentialByName(ctx, ctx.Doer.ID, name)
+ if err != nil && !auth.IsErrWebAuthnCredentialNotExist(err) {
+ ctx.ServerError("GetWebAuthnCredentialsByUID", err)
+ return
+ }
+ if dbCred != nil {
+ ctx.Error(http.StatusConflict, "Name already taken")
+ return
+ }
+
+ // Create the credential
+ _, err = auth.CreateCredential(ctx, ctx.Doer.ID, name, cred)
+ if err != nil {
+ ctx.ServerError("CreateCredential", err)
+ return
+ }
+ _ = ctx.Session.Delete("webauthnName")
+
+ ctx.JSON(http.StatusCreated, cred)
+}
+
+// WebauthnDelete deletes an security key by id
+func WebauthnDelete(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.WebauthnDeleteForm)
+ cred, err := auth.GetWebAuthnCredentialByID(ctx, form.ID)
+ if err != nil || cred.UserID != ctx.Doer.ID {
+ if err != nil && !auth.IsErrWebAuthnCredentialNotExist(err) {
+ log.Error("GetWebAuthnCredentialByID: %v", err)
+ }
+
+ ctx.JSONRedirect(setting.AppSubURL + "/user/settings/security")
+ return
+ }
+
+ if _, err := auth.DeleteCredential(ctx, form.ID, ctx.Doer.ID); err != nil {
+ ctx.ServerError("GetWebAuthnCredentialByID", err)
+ return
+ }
+
+ if err := mailer.SendRemovedSecurityKey(ctx, ctx.Doer, cred.Name); err != nil {
+ ctx.ServerError("SendRemovedSecurityKey", err)
+ return
+ }
+
+ ctx.JSONRedirect(setting.AppSubURL + "/user/settings/security")
+}
diff --git a/routers/web/user/setting/webhooks.go b/routers/web/user/setting/webhooks.go
new file mode 100644
index 0000000..3cc67d9
--- /dev/null
+++ b/routers/web/user/setting/webhooks.go
@@ -0,0 +1,49 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+ "net/http"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/webhook"
+ "code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/services/context"
+ webhook_service "code.gitea.io/gitea/services/webhook"
+)
+
+const (
+ tplSettingsHooks base.TplName = "user/settings/hooks"
+)
+
+// Webhooks render webhook list page
+func Webhooks(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("settings")
+ ctx.Data["PageIsSettingsHooks"] = true
+ ctx.Data["BaseLink"] = setting.AppSubURL + "/user/settings/hooks"
+ ctx.Data["BaseLinkNew"] = setting.AppSubURL + "/user/settings/hooks"
+ ctx.Data["WebhookList"] = webhook_service.List()
+ ctx.Data["Description"] = ctx.Tr("settings.hooks.desc")
+
+ ws, err := db.Find[webhook.Webhook](ctx, webhook.ListWebhookOptions{OwnerID: ctx.Doer.ID})
+ if err != nil {
+ ctx.ServerError("ListWebhooksByOpts", err)
+ return
+ }
+
+ ctx.Data["Webhooks"] = ws
+ ctx.HTML(http.StatusOK, tplSettingsHooks)
+}
+
+// DeleteWebhook response for delete webhook
+func DeleteWebhook(ctx *context.Context) {
+ if err := webhook.DeleteWebhookByOwnerID(ctx, ctx.Doer.ID, ctx.FormInt64("id")); err != nil {
+ ctx.Flash.Error("DeleteWebhookByOwnerID: " + err.Error())
+ } else {
+ ctx.Flash.Success(ctx.Tr("repo.settings.webhook_deletion_success"))
+ }
+
+ ctx.JSONRedirect(setting.AppSubURL + "/user/settings/hooks")
+}