From 70f8d10fc5b1a5c8a80e5c05ef6e864154d9b272 Mon Sep 17 00:00:00 2001
From: Nanguan Lin <70063547+lng2020@users.noreply.github.com>
Date: Wed, 27 Sep 2023 20:25:38 +0800
Subject: [PATCH] move the `gitea admin` subcommands into separate files
 (#27307)

As title.
Probably it's better to put those sub cmd to different dirs. Will do
that in the future.
---
 cmd/admin.go                | 617 +-----------------------------------
 cmd/admin_auth.go           | 109 +++++++
 cmd/admin_auth_ldap.go      |  10 +-
 cmd/admin_auth_ldap_test.go |   8 +-
 cmd/admin_auth_oauth.go     | 298 +++++++++++++++++
 cmd/admin_auth_stmp.go      | 201 ++++++++++++
 cmd/admin_regenerate.go     |  46 +++
 7 files changed, 670 insertions(+), 619 deletions(-)
 create mode 100644 cmd/admin_auth.go
 create mode 100644 cmd/admin_auth_oauth.go
 create mode 100644 cmd/admin_auth_stmp.go
 create mode 100644 cmd/admin_regenerate.go

diff --git a/cmd/admin.go b/cmd/admin.go
index d49dcf13cb..49d0e4ef74 100644
--- a/cmd/admin.go
+++ b/cmd/admin.go
@@ -6,26 +6,13 @@ package cmd
 
 import (
 	"context"
-	"errors"
 	"fmt"
-	"net/url"
-	"os"
-	"strings"
-	"text/tabwriter"
 
-	asymkey_model "code.gitea.io/gitea/models/asymkey"
-	auth_model "code.gitea.io/gitea/models/auth"
 	"code.gitea.io/gitea/models/db"
 	repo_model "code.gitea.io/gitea/models/repo"
 	"code.gitea.io/gitea/modules/git"
-	"code.gitea.io/gitea/modules/graceful"
 	"code.gitea.io/gitea/modules/log"
 	repo_module "code.gitea.io/gitea/modules/repository"
-	"code.gitea.io/gitea/modules/util"
-	auth_service "code.gitea.io/gitea/services/auth"
-	"code.gitea.io/gitea/services/auth/source/oauth2"
-	"code.gitea.io/gitea/services/auth/source/smtp"
-	repo_service "code.gitea.io/gitea/services/repository"
 
 	"github.com/urfave/cli/v2"
 )
@@ -59,28 +46,16 @@ var (
 		},
 	}
 
-	microcmdRegenHooks = &cli.Command{
-		Name:   "hooks",
-		Usage:  "Regenerate git-hooks",
-		Action: runRegenerateHooks,
-	}
-
-	microcmdRegenKeys = &cli.Command{
-		Name:   "keys",
-		Usage:  "Regenerate authorized_keys file",
-		Action: runRegenerateKeys,
-	}
-
 	subcmdAuth = &cli.Command{
 		Name:  "auth",
 		Usage: "Modify external auth providers",
 		Subcommands: []*cli.Command{
 			microcmdAuthAddOauth,
 			microcmdAuthUpdateOauth,
-			cmdAuthAddLdapBindDn,
-			cmdAuthUpdateLdapBindDn,
-			cmdAuthAddLdapSimpleAuth,
-			cmdAuthUpdateLdapSimpleAuth,
+			microcmdAuthAddLdapBindDn,
+			microcmdAuthUpdateLdapBindDn,
+			microcmdAuthAddLdapSimpleAuth,
+			microcmdAuthUpdateLdapSimpleAuth,
 			microcmdAuthAddSMTP,
 			microcmdAuthUpdateSMTP,
 			microcmdAuthList,
@@ -88,170 +63,6 @@ var (
 		},
 	}
 
-	microcmdAuthList = &cli.Command{
-		Name:   "list",
-		Usage:  "List auth sources",
-		Action: runListAuth,
-		Flags: []cli.Flag{
-			&cli.IntFlag{
-				Name:  "min-width",
-				Usage: "Minimal cell width including any padding for the formatted table",
-				Value: 0,
-			},
-			&cli.IntFlag{
-				Name:  "tab-width",
-				Usage: "width of tab characters in formatted table (equivalent number of spaces)",
-				Value: 8,
-			},
-			&cli.IntFlag{
-				Name:  "padding",
-				Usage: "padding added to a cell before computing its width",
-				Value: 1,
-			},
-			&cli.StringFlag{
-				Name:  "pad-char",
-				Usage: `ASCII char used for padding if padchar == '\\t', the Writer will assume that the width of a '\\t' in the formatted output is tabwidth, and cells are left-aligned independent of align_left (for correct-looking results, tabwidth must correspond to the tab width in the viewer displaying the result)`,
-				Value: "\t",
-			},
-			&cli.BoolFlag{
-				Name:  "vertical-bars",
-				Usage: "Set to true to print vertical bars between columns",
-			},
-		},
-	}
-
-	idFlag = &cli.Int64Flag{
-		Name:  "id",
-		Usage: "ID of authentication source",
-	}
-
-	microcmdAuthDelete = &cli.Command{
-		Name:   "delete",
-		Usage:  "Delete specific auth source",
-		Flags:  []cli.Flag{idFlag},
-		Action: runDeleteAuth,
-	}
-
-	oauthCLIFlags = []cli.Flag{
-		&cli.StringFlag{
-			Name:  "name",
-			Value: "",
-			Usage: "Application Name",
-		},
-		&cli.StringFlag{
-			Name:  "provider",
-			Value: "",
-			Usage: "OAuth2 Provider",
-		},
-		&cli.StringFlag{
-			Name:  "key",
-			Value: "",
-			Usage: "Client ID (Key)",
-		},
-		&cli.StringFlag{
-			Name:  "secret",
-			Value: "",
-			Usage: "Client Secret",
-		},
-		&cli.StringFlag{
-			Name:  "auto-discover-url",
-			Value: "",
-			Usage: "OpenID Connect Auto Discovery URL (only required when using OpenID Connect as provider)",
-		},
-		&cli.StringFlag{
-			Name:  "use-custom-urls",
-			Value: "false",
-			Usage: "Use custom URLs for GitLab/GitHub OAuth endpoints",
-		},
-		&cli.StringFlag{
-			Name:  "custom-tenant-id",
-			Value: "",
-			Usage: "Use custom Tenant ID for OAuth endpoints",
-		},
-		&cli.StringFlag{
-			Name:  "custom-auth-url",
-			Value: "",
-			Usage: "Use a custom Authorization URL (option for GitLab/GitHub)",
-		},
-		&cli.StringFlag{
-			Name:  "custom-token-url",
-			Value: "",
-			Usage: "Use a custom Token URL (option for GitLab/GitHub)",
-		},
-		&cli.StringFlag{
-			Name:  "custom-profile-url",
-			Value: "",
-			Usage: "Use a custom Profile URL (option for GitLab/GitHub)",
-		},
-		&cli.StringFlag{
-			Name:  "custom-email-url",
-			Value: "",
-			Usage: "Use a custom Email URL (option for GitHub)",
-		},
-		&cli.StringFlag{
-			Name:  "icon-url",
-			Value: "",
-			Usage: "Custom icon URL for OAuth2 login source",
-		},
-		&cli.BoolFlag{
-			Name:  "skip-local-2fa",
-			Usage: "Set to true to skip local 2fa for users authenticated by this source",
-		},
-		&cli.StringSliceFlag{
-			Name:  "scopes",
-			Value: nil,
-			Usage: "Scopes to request when to authenticate against this OAuth2 source",
-		},
-		&cli.StringFlag{
-			Name:  "required-claim-name",
-			Value: "",
-			Usage: "Claim name that has to be set to allow users to login with this source",
-		},
-		&cli.StringFlag{
-			Name:  "required-claim-value",
-			Value: "",
-			Usage: "Claim value that has to be set to allow users to login with this source",
-		},
-		&cli.StringFlag{
-			Name:  "group-claim-name",
-			Value: "",
-			Usage: "Claim name providing group names for this source",
-		},
-		&cli.StringFlag{
-			Name:  "admin-group",
-			Value: "",
-			Usage: "Group Claim value for administrator users",
-		},
-		&cli.StringFlag{
-			Name:  "restricted-group",
-			Value: "",
-			Usage: "Group Claim value for restricted users",
-		},
-		&cli.StringFlag{
-			Name:  "group-team-map",
-			Value: "",
-			Usage: "JSON mapping between groups and org teams",
-		},
-		&cli.BoolFlag{
-			Name:  "group-team-map-removal",
-			Usage: "Activate automatic team membership removal depending on groups",
-		},
-	}
-
-	microcmdAuthUpdateOauth = &cli.Command{
-		Name:   "update-oauth",
-		Usage:  "Update existing Oauth authentication source",
-		Action: runUpdateOauth,
-		Flags:  append(oauthCLIFlags[:1], append([]cli.Flag{idFlag}, oauthCLIFlags[1:]...)...),
-	}
-
-	microcmdAuthAddOauth = &cli.Command{
-		Name:   "add-oauth",
-		Usage:  "Add new Oauth authentication source",
-		Action: runAddOauth,
-		Flags:  oauthCLIFlags,
-	}
-
 	subcmdSendMail = &cli.Command{
 		Name:   "sendmail",
 		Usage:  "Send a message to all users",
@@ -275,75 +86,9 @@ var (
 		},
 	}
 
-	smtpCLIFlags = []cli.Flag{
-		&cli.StringFlag{
-			Name:  "name",
-			Value: "",
-			Usage: "Application Name",
-		},
-		&cli.StringFlag{
-			Name:  "auth-type",
-			Value: "PLAIN",
-			Usage: "SMTP Authentication Type (PLAIN/LOGIN/CRAM-MD5) default PLAIN",
-		},
-		&cli.StringFlag{
-			Name:  "host",
-			Value: "",
-			Usage: "SMTP Host",
-		},
-		&cli.IntFlag{
-			Name:  "port",
-			Usage: "SMTP Port",
-		},
-		&cli.BoolFlag{
-			Name:  "force-smtps",
-			Usage: "SMTPS is always used on port 465. Set this to force SMTPS on other ports.",
-			Value: true,
-		},
-		&cli.BoolFlag{
-			Name:  "skip-verify",
-			Usage: "Skip TLS verify.",
-			Value: true,
-		},
-		&cli.StringFlag{
-			Name:  "helo-hostname",
-			Value: "",
-			Usage: "Hostname sent with HELO. Leave blank to send current hostname",
-		},
-		&cli.BoolFlag{
-			Name:  "disable-helo",
-			Usage: "Disable SMTP helo.",
-			Value: true,
-		},
-		&cli.StringFlag{
-			Name:  "allowed-domains",
-			Value: "",
-			Usage: "Leave empty to allow all domains. Separate multiple domains with a comma (',')",
-		},
-		&cli.BoolFlag{
-			Name:  "skip-local-2fa",
-			Usage: "Skip 2FA to log on.",
-			Value: true,
-		},
-		&cli.BoolFlag{
-			Name:  "active",
-			Usage: "This Authentication Source is Activated.",
-			Value: true,
-		},
-	}
-
-	microcmdAuthAddSMTP = &cli.Command{
-		Name:   "add-smtp",
-		Usage:  "Add new SMTP authentication source",
-		Action: runAddSMTP,
-		Flags:  smtpCLIFlags,
-	}
-
-	microcmdAuthUpdateSMTP = &cli.Command{
-		Name:   "update-smtp",
-		Usage:  "Update existing SMTP authentication source",
-		Action: runUpdateSMTP,
-		Flags:  append(smtpCLIFlags[:1], append([]cli.Flag{idFlag}, smtpCLIFlags[1:]...)...),
+	idFlag = &cli.Int64Flag{
+		Name:  "id",
+		Usage: "ID of authentication source",
 	}
 )
 
@@ -420,351 +165,3 @@ func getReleaseCount(ctx context.Context, id int64) (int64, error) {
 		},
 	)
 }
-
-func runRegenerateHooks(_ *cli.Context) error {
-	ctx, cancel := installSignals()
-	defer cancel()
-
-	if err := initDB(ctx); err != nil {
-		return err
-	}
-	return repo_service.SyncRepositoryHooks(graceful.GetManager().ShutdownContext())
-}
-
-func runRegenerateKeys(_ *cli.Context) error {
-	ctx, cancel := installSignals()
-	defer cancel()
-
-	if err := initDB(ctx); err != nil {
-		return err
-	}
-	return asymkey_model.RewriteAllPublicKeys(ctx)
-}
-
-func parseOAuth2Config(c *cli.Context) *oauth2.Source {
-	var customURLMapping *oauth2.CustomURLMapping
-	if c.IsSet("use-custom-urls") {
-		customURLMapping = &oauth2.CustomURLMapping{
-			TokenURL:   c.String("custom-token-url"),
-			AuthURL:    c.String("custom-auth-url"),
-			ProfileURL: c.String("custom-profile-url"),
-			EmailURL:   c.String("custom-email-url"),
-			Tenant:     c.String("custom-tenant-id"),
-		}
-	} else {
-		customURLMapping = nil
-	}
-	return &oauth2.Source{
-		Provider:                      c.String("provider"),
-		ClientID:                      c.String("key"),
-		ClientSecret:                  c.String("secret"),
-		OpenIDConnectAutoDiscoveryURL: c.String("auto-discover-url"),
-		CustomURLMapping:              customURLMapping,
-		IconURL:                       c.String("icon-url"),
-		SkipLocalTwoFA:                c.Bool("skip-local-2fa"),
-		Scopes:                        c.StringSlice("scopes"),
-		RequiredClaimName:             c.String("required-claim-name"),
-		RequiredClaimValue:            c.String("required-claim-value"),
-		GroupClaimName:                c.String("group-claim-name"),
-		AdminGroup:                    c.String("admin-group"),
-		RestrictedGroup:               c.String("restricted-group"),
-		GroupTeamMap:                  c.String("group-team-map"),
-		GroupTeamMapRemoval:           c.Bool("group-team-map-removal"),
-	}
-}
-
-func runAddOauth(c *cli.Context) error {
-	ctx, cancel := installSignals()
-	defer cancel()
-
-	if err := initDB(ctx); err != nil {
-		return err
-	}
-
-	config := parseOAuth2Config(c)
-	if config.Provider == "openidConnect" {
-		discoveryURL, err := url.Parse(config.OpenIDConnectAutoDiscoveryURL)
-		if err != nil || (discoveryURL.Scheme != "http" && discoveryURL.Scheme != "https") {
-			return fmt.Errorf("invalid Auto Discovery URL: %s (this must be a valid URL starting with http:// or https://)", config.OpenIDConnectAutoDiscoveryURL)
-		}
-	}
-
-	return auth_model.CreateSource(&auth_model.Source{
-		Type:     auth_model.OAuth2,
-		Name:     c.String("name"),
-		IsActive: true,
-		Cfg:      config,
-	})
-}
-
-func runUpdateOauth(c *cli.Context) error {
-	if !c.IsSet("id") {
-		return fmt.Errorf("--id flag is missing")
-	}
-
-	ctx, cancel := installSignals()
-	defer cancel()
-
-	if err := initDB(ctx); err != nil {
-		return err
-	}
-
-	source, err := auth_model.GetSourceByID(c.Int64("id"))
-	if err != nil {
-		return err
-	}
-
-	oAuth2Config := source.Cfg.(*oauth2.Source)
-
-	if c.IsSet("name") {
-		source.Name = c.String("name")
-	}
-
-	if c.IsSet("provider") {
-		oAuth2Config.Provider = c.String("provider")
-	}
-
-	if c.IsSet("key") {
-		oAuth2Config.ClientID = c.String("key")
-	}
-
-	if c.IsSet("secret") {
-		oAuth2Config.ClientSecret = c.String("secret")
-	}
-
-	if c.IsSet("auto-discover-url") {
-		oAuth2Config.OpenIDConnectAutoDiscoveryURL = c.String("auto-discover-url")
-	}
-
-	if c.IsSet("icon-url") {
-		oAuth2Config.IconURL = c.String("icon-url")
-	}
-
-	if c.IsSet("scopes") {
-		oAuth2Config.Scopes = c.StringSlice("scopes")
-	}
-
-	if c.IsSet("required-claim-name") {
-		oAuth2Config.RequiredClaimName = c.String("required-claim-name")
-	}
-	if c.IsSet("required-claim-value") {
-		oAuth2Config.RequiredClaimValue = c.String("required-claim-value")
-	}
-
-	if c.IsSet("group-claim-name") {
-		oAuth2Config.GroupClaimName = c.String("group-claim-name")
-	}
-	if c.IsSet("admin-group") {
-		oAuth2Config.AdminGroup = c.String("admin-group")
-	}
-	if c.IsSet("restricted-group") {
-		oAuth2Config.RestrictedGroup = c.String("restricted-group")
-	}
-	if c.IsSet("group-team-map") {
-		oAuth2Config.GroupTeamMap = c.String("group-team-map")
-	}
-	if c.IsSet("group-team-map-removal") {
-		oAuth2Config.GroupTeamMapRemoval = c.Bool("group-team-map-removal")
-	}
-
-	// update custom URL mapping
-	customURLMapping := &oauth2.CustomURLMapping{}
-
-	if oAuth2Config.CustomURLMapping != nil {
-		customURLMapping.TokenURL = oAuth2Config.CustomURLMapping.TokenURL
-		customURLMapping.AuthURL = oAuth2Config.CustomURLMapping.AuthURL
-		customURLMapping.ProfileURL = oAuth2Config.CustomURLMapping.ProfileURL
-		customURLMapping.EmailURL = oAuth2Config.CustomURLMapping.EmailURL
-		customURLMapping.Tenant = oAuth2Config.CustomURLMapping.Tenant
-	}
-	if c.IsSet("use-custom-urls") && c.IsSet("custom-token-url") {
-		customURLMapping.TokenURL = c.String("custom-token-url")
-	}
-
-	if c.IsSet("use-custom-urls") && c.IsSet("custom-auth-url") {
-		customURLMapping.AuthURL = c.String("custom-auth-url")
-	}
-
-	if c.IsSet("use-custom-urls") && c.IsSet("custom-profile-url") {
-		customURLMapping.ProfileURL = c.String("custom-profile-url")
-	}
-
-	if c.IsSet("use-custom-urls") && c.IsSet("custom-email-url") {
-		customURLMapping.EmailURL = c.String("custom-email-url")
-	}
-
-	if c.IsSet("use-custom-urls") && c.IsSet("custom-tenant-id") {
-		customURLMapping.Tenant = c.String("custom-tenant-id")
-	}
-
-	oAuth2Config.CustomURLMapping = customURLMapping
-	source.Cfg = oAuth2Config
-
-	return auth_model.UpdateSource(source)
-}
-
-func parseSMTPConfig(c *cli.Context, conf *smtp.Source) error {
-	if c.IsSet("auth-type") {
-		conf.Auth = c.String("auth-type")
-		validAuthTypes := []string{"PLAIN", "LOGIN", "CRAM-MD5"}
-		if !util.SliceContainsString(validAuthTypes, strings.ToUpper(c.String("auth-type"))) {
-			return errors.New("Auth must be one of PLAIN/LOGIN/CRAM-MD5")
-		}
-		conf.Auth = c.String("auth-type")
-	}
-	if c.IsSet("host") {
-		conf.Host = c.String("host")
-	}
-	if c.IsSet("port") {
-		conf.Port = c.Int("port")
-	}
-	if c.IsSet("allowed-domains") {
-		conf.AllowedDomains = c.String("allowed-domains")
-	}
-	if c.IsSet("force-smtps") {
-		conf.ForceSMTPS = c.Bool("force-smtps")
-	}
-	if c.IsSet("skip-verify") {
-		conf.SkipVerify = c.Bool("skip-verify")
-	}
-	if c.IsSet("helo-hostname") {
-		conf.HeloHostname = c.String("helo-hostname")
-	}
-	if c.IsSet("disable-helo") {
-		conf.DisableHelo = c.Bool("disable-helo")
-	}
-	if c.IsSet("skip-local-2fa") {
-		conf.SkipLocalTwoFA = c.Bool("skip-local-2fa")
-	}
-	return nil
-}
-
-func runAddSMTP(c *cli.Context) error {
-	ctx, cancel := installSignals()
-	defer cancel()
-
-	if err := initDB(ctx); err != nil {
-		return err
-	}
-
-	if !c.IsSet("name") || len(c.String("name")) == 0 {
-		return errors.New("name must be set")
-	}
-	if !c.IsSet("host") || len(c.String("host")) == 0 {
-		return errors.New("host must be set")
-	}
-	if !c.IsSet("port") {
-		return errors.New("port must be set")
-	}
-	active := true
-	if c.IsSet("active") {
-		active = c.Bool("active")
-	}
-
-	var smtpConfig smtp.Source
-	if err := parseSMTPConfig(c, &smtpConfig); err != nil {
-		return err
-	}
-
-	// If not set default to PLAIN
-	if len(smtpConfig.Auth) == 0 {
-		smtpConfig.Auth = "PLAIN"
-	}
-
-	return auth_model.CreateSource(&auth_model.Source{
-		Type:     auth_model.SMTP,
-		Name:     c.String("name"),
-		IsActive: active,
-		Cfg:      &smtpConfig,
-	})
-}
-
-func runUpdateSMTP(c *cli.Context) error {
-	if !c.IsSet("id") {
-		return fmt.Errorf("--id flag is missing")
-	}
-
-	ctx, cancel := installSignals()
-	defer cancel()
-
-	if err := initDB(ctx); err != nil {
-		return err
-	}
-
-	source, err := auth_model.GetSourceByID(c.Int64("id"))
-	if err != nil {
-		return err
-	}
-
-	smtpConfig := source.Cfg.(*smtp.Source)
-
-	if err := parseSMTPConfig(c, smtpConfig); err != nil {
-		return err
-	}
-
-	if c.IsSet("name") {
-		source.Name = c.String("name")
-	}
-
-	if c.IsSet("active") {
-		source.IsActive = c.Bool("active")
-	}
-
-	source.Cfg = smtpConfig
-
-	return auth_model.UpdateSource(source)
-}
-
-func runListAuth(c *cli.Context) error {
-	ctx, cancel := installSignals()
-	defer cancel()
-
-	if err := initDB(ctx); err != nil {
-		return err
-	}
-
-	authSources, err := auth_model.Sources()
-	if err != nil {
-		return err
-	}
-
-	flags := tabwriter.AlignRight
-	if c.Bool("vertical-bars") {
-		flags |= tabwriter.Debug
-	}
-
-	padChar := byte('\t')
-	if len(c.String("pad-char")) > 0 {
-		padChar = c.String("pad-char")[0]
-	}
-
-	// loop through each source and print
-	w := tabwriter.NewWriter(os.Stdout, c.Int("min-width"), c.Int("tab-width"), c.Int("padding"), padChar, flags)
-	fmt.Fprintf(w, "ID\tName\tType\tEnabled\n")
-	for _, source := range authSources {
-		fmt.Fprintf(w, "%d\t%s\t%s\t%t\n", source.ID, source.Name, source.Type.String(), source.IsActive)
-	}
-	w.Flush()
-
-	return nil
-}
-
-func runDeleteAuth(c *cli.Context) error {
-	if !c.IsSet("id") {
-		return fmt.Errorf("--id flag is missing")
-	}
-
-	ctx, cancel := installSignals()
-	defer cancel()
-
-	if err := initDB(ctx); err != nil {
-		return err
-	}
-
-	source, err := auth_model.GetSourceByID(c.Int64("id"))
-	if err != nil {
-		return err
-	}
-
-	return auth_service.DeleteSource(source)
-}
diff --git a/cmd/admin_auth.go b/cmd/admin_auth.go
new file mode 100644
index 0000000000..ef826a4893
--- /dev/null
+++ b/cmd/admin_auth.go
@@ -0,0 +1,109 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package cmd
+
+import (
+	"fmt"
+	"os"
+	"text/tabwriter"
+
+	auth_model "code.gitea.io/gitea/models/auth"
+	auth_service "code.gitea.io/gitea/services/auth"
+
+	"github.com/urfave/cli/v2"
+)
+
+var (
+	microcmdAuthDelete = &cli.Command{
+		Name:   "delete",
+		Usage:  "Delete specific auth source",
+		Flags:  []cli.Flag{idFlag},
+		Action: runDeleteAuth,
+	}
+	microcmdAuthList = &cli.Command{
+		Name:   "list",
+		Usage:  "List auth sources",
+		Action: runListAuth,
+		Flags: []cli.Flag{
+			&cli.IntFlag{
+				Name:  "min-width",
+				Usage: "Minimal cell width including any padding for the formatted table",
+				Value: 0,
+			},
+			&cli.IntFlag{
+				Name:  "tab-width",
+				Usage: "width of tab characters in formatted table (equivalent number of spaces)",
+				Value: 8,
+			},
+			&cli.IntFlag{
+				Name:  "padding",
+				Usage: "padding added to a cell before computing its width",
+				Value: 1,
+			},
+			&cli.StringFlag{
+				Name:  "pad-char",
+				Usage: `ASCII char used for padding if padchar == '\\t', the Writer will assume that the width of a '\\t' in the formatted output is tabwidth, and cells are left-aligned independent of align_left (for correct-looking results, tabwidth must correspond to the tab width in the viewer displaying the result)`,
+				Value: "\t",
+			},
+			&cli.BoolFlag{
+				Name:  "vertical-bars",
+				Usage: "Set to true to print vertical bars between columns",
+			},
+		},
+	}
+)
+
+func runListAuth(c *cli.Context) error {
+	ctx, cancel := installSignals()
+	defer cancel()
+
+	if err := initDB(ctx); err != nil {
+		return err
+	}
+
+	authSources, err := auth_model.Sources()
+	if err != nil {
+		return err
+	}
+
+	flags := tabwriter.AlignRight
+	if c.Bool("vertical-bars") {
+		flags |= tabwriter.Debug
+	}
+
+	padChar := byte('\t')
+	if len(c.String("pad-char")) > 0 {
+		padChar = c.String("pad-char")[0]
+	}
+
+	// loop through each source and print
+	w := tabwriter.NewWriter(os.Stdout, c.Int("min-width"), c.Int("tab-width"), c.Int("padding"), padChar, flags)
+	fmt.Fprintf(w, "ID\tName\tType\tEnabled\n")
+	for _, source := range authSources {
+		fmt.Fprintf(w, "%d\t%s\t%s\t%t\n", source.ID, source.Name, source.Type.String(), source.IsActive)
+	}
+	w.Flush()
+
+	return nil
+}
+
+func runDeleteAuth(c *cli.Context) error {
+	if !c.IsSet("id") {
+		return fmt.Errorf("--id flag is missing")
+	}
+
+	ctx, cancel := installSignals()
+	defer cancel()
+
+	if err := initDB(ctx); err != nil {
+		return err
+	}
+
+	source, err := auth_model.GetSourceByID(c.Int64("id"))
+	if err != nil {
+		return err
+	}
+
+	return auth_service.DeleteSource(source)
+}
diff --git a/cmd/admin_auth_ldap.go b/cmd/admin_auth_ldap.go
index cfa1a23235..111bc7955c 100644
--- a/cmd/admin_auth_ldap.go
+++ b/cmd/admin_auth_ldap.go
@@ -132,10 +132,10 @@ var (
 	ldapSimpleAuthCLIFlags = append(commonLdapCLIFlags,
 		&cli.StringFlag{
 			Name:  "user-dn",
-			Usage: "The user’s DN.",
+			Usage: "The user's DN.",
 		})
 
-	cmdAuthAddLdapBindDn = &cli.Command{
+	microcmdAuthAddLdapBindDn = &cli.Command{
 		Name:  "add-ldap",
 		Usage: "Add new LDAP (via Bind DN) authentication source",
 		Action: func(c *cli.Context) error {
@@ -144,7 +144,7 @@ var (
 		Flags: ldapBindDnCLIFlags,
 	}
 
-	cmdAuthUpdateLdapBindDn = &cli.Command{
+	microcmdAuthUpdateLdapBindDn = &cli.Command{
 		Name:  "update-ldap",
 		Usage: "Update existing LDAP (via Bind DN) authentication source",
 		Action: func(c *cli.Context) error {
@@ -153,7 +153,7 @@ var (
 		Flags: append([]cli.Flag{idFlag}, ldapBindDnCLIFlags...),
 	}
 
-	cmdAuthAddLdapSimpleAuth = &cli.Command{
+	microcmdAuthAddLdapSimpleAuth = &cli.Command{
 		Name:  "add-ldap-simple",
 		Usage: "Add new LDAP (simple auth) authentication source",
 		Action: func(c *cli.Context) error {
@@ -162,7 +162,7 @@ var (
 		Flags: ldapSimpleAuthCLIFlags,
 	}
 
-	cmdAuthUpdateLdapSimpleAuth = &cli.Command{
+	microcmdAuthUpdateLdapSimpleAuth = &cli.Command{
 		Name:  "update-ldap-simple",
 		Usage: "Update existing LDAP (simple auth) authentication source",
 		Action: func(c *cli.Context) error {
diff --git a/cmd/admin_auth_ldap_test.go b/cmd/admin_auth_ldap_test.go
index 210a6463c3..228c9dd3ed 100644
--- a/cmd/admin_auth_ldap_test.go
+++ b/cmd/admin_auth_ldap_test.go
@@ -226,7 +226,7 @@ func TestAddLdapBindDn(t *testing.T) {
 
 		// Create a copy of command to test
 		app := cli.NewApp()
-		app.Flags = cmdAuthAddLdapBindDn.Flags
+		app.Flags = microcmdAuthAddLdapBindDn.Flags
 		app.Action = service.addLdapBindDn
 
 		// Run it
@@ -457,7 +457,7 @@ func TestAddLdapSimpleAuth(t *testing.T) {
 
 		// Create a copy of command to test
 		app := cli.NewApp()
-		app.Flags = cmdAuthAddLdapSimpleAuth.Flags
+		app.Flags = microcmdAuthAddLdapSimpleAuth.Flags
 		app.Action = service.addLdapSimpleAuth
 
 		// Run it
@@ -920,7 +920,7 @@ func TestUpdateLdapBindDn(t *testing.T) {
 
 		// Create a copy of command to test
 		app := cli.NewApp()
-		app.Flags = cmdAuthUpdateLdapBindDn.Flags
+		app.Flags = microcmdAuthUpdateLdapBindDn.Flags
 		app.Action = service.updateLdapBindDn
 
 		// Run it
@@ -1310,7 +1310,7 @@ func TestUpdateLdapSimpleAuth(t *testing.T) {
 
 		// Create a copy of command to test
 		app := cli.NewApp()
-		app.Flags = cmdAuthUpdateLdapSimpleAuth.Flags
+		app.Flags = microcmdAuthUpdateLdapSimpleAuth.Flags
 		app.Action = service.updateLdapSimpleAuth
 
 		// Run it
diff --git a/cmd/admin_auth_oauth.go b/cmd/admin_auth_oauth.go
new file mode 100644
index 0000000000..cc54ac9454
--- /dev/null
+++ b/cmd/admin_auth_oauth.go
@@ -0,0 +1,298 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package cmd
+
+import (
+	"fmt"
+	"net/url"
+
+	auth_model "code.gitea.io/gitea/models/auth"
+	"code.gitea.io/gitea/services/auth/source/oauth2"
+
+	"github.com/urfave/cli/v2"
+)
+
+var (
+	oauthCLIFlags = []cli.Flag{
+		&cli.StringFlag{
+			Name:  "name",
+			Value: "",
+			Usage: "Application Name",
+		},
+		&cli.StringFlag{
+			Name:  "provider",
+			Value: "",
+			Usage: "OAuth2 Provider",
+		},
+		&cli.StringFlag{
+			Name:  "key",
+			Value: "",
+			Usage: "Client ID (Key)",
+		},
+		&cli.StringFlag{
+			Name:  "secret",
+			Value: "",
+			Usage: "Client Secret",
+		},
+		&cli.StringFlag{
+			Name:  "auto-discover-url",
+			Value: "",
+			Usage: "OpenID Connect Auto Discovery URL (only required when using OpenID Connect as provider)",
+		},
+		&cli.StringFlag{
+			Name:  "use-custom-urls",
+			Value: "false",
+			Usage: "Use custom URLs for GitLab/GitHub OAuth endpoints",
+		},
+		&cli.StringFlag{
+			Name:  "custom-tenant-id",
+			Value: "",
+			Usage: "Use custom Tenant ID for OAuth endpoints",
+		},
+		&cli.StringFlag{
+			Name:  "custom-auth-url",
+			Value: "",
+			Usage: "Use a custom Authorization URL (option for GitLab/GitHub)",
+		},
+		&cli.StringFlag{
+			Name:  "custom-token-url",
+			Value: "",
+			Usage: "Use a custom Token URL (option for GitLab/GitHub)",
+		},
+		&cli.StringFlag{
+			Name:  "custom-profile-url",
+			Value: "",
+			Usage: "Use a custom Profile URL (option for GitLab/GitHub)",
+		},
+		&cli.StringFlag{
+			Name:  "custom-email-url",
+			Value: "",
+			Usage: "Use a custom Email URL (option for GitHub)",
+		},
+		&cli.StringFlag{
+			Name:  "icon-url",
+			Value: "",
+			Usage: "Custom icon URL for OAuth2 login source",
+		},
+		&cli.BoolFlag{
+			Name:  "skip-local-2fa",
+			Usage: "Set to true to skip local 2fa for users authenticated by this source",
+		},
+		&cli.StringSliceFlag{
+			Name:  "scopes",
+			Value: nil,
+			Usage: "Scopes to request when to authenticate against this OAuth2 source",
+		},
+		&cli.StringFlag{
+			Name:  "required-claim-name",
+			Value: "",
+			Usage: "Claim name that has to be set to allow users to login with this source",
+		},
+		&cli.StringFlag{
+			Name:  "required-claim-value",
+			Value: "",
+			Usage: "Claim value that has to be set to allow users to login with this source",
+		},
+		&cli.StringFlag{
+			Name:  "group-claim-name",
+			Value: "",
+			Usage: "Claim name providing group names for this source",
+		},
+		&cli.StringFlag{
+			Name:  "admin-group",
+			Value: "",
+			Usage: "Group Claim value for administrator users",
+		},
+		&cli.StringFlag{
+			Name:  "restricted-group",
+			Value: "",
+			Usage: "Group Claim value for restricted users",
+		},
+		&cli.StringFlag{
+			Name:  "group-team-map",
+			Value: "",
+			Usage: "JSON mapping between groups and org teams",
+		},
+		&cli.BoolFlag{
+			Name:  "group-team-map-removal",
+			Usage: "Activate automatic team membership removal depending on groups",
+		},
+	}
+
+	microcmdAuthAddOauth = &cli.Command{
+		Name:   "add-oauth",
+		Usage:  "Add new Oauth authentication source",
+		Action: runAddOauth,
+		Flags:  oauthCLIFlags,
+	}
+
+	microcmdAuthUpdateOauth = &cli.Command{
+		Name:   "update-oauth",
+		Usage:  "Update existing Oauth authentication source",
+		Action: runUpdateOauth,
+		Flags:  append(oauthCLIFlags[:1], append([]cli.Flag{idFlag}, oauthCLIFlags[1:]...)...),
+	}
+)
+
+func parseOAuth2Config(c *cli.Context) *oauth2.Source {
+	var customURLMapping *oauth2.CustomURLMapping
+	if c.IsSet("use-custom-urls") {
+		customURLMapping = &oauth2.CustomURLMapping{
+			TokenURL:   c.String("custom-token-url"),
+			AuthURL:    c.String("custom-auth-url"),
+			ProfileURL: c.String("custom-profile-url"),
+			EmailURL:   c.String("custom-email-url"),
+			Tenant:     c.String("custom-tenant-id"),
+		}
+	} else {
+		customURLMapping = nil
+	}
+	return &oauth2.Source{
+		Provider:                      c.String("provider"),
+		ClientID:                      c.String("key"),
+		ClientSecret:                  c.String("secret"),
+		OpenIDConnectAutoDiscoveryURL: c.String("auto-discover-url"),
+		CustomURLMapping:              customURLMapping,
+		IconURL:                       c.String("icon-url"),
+		SkipLocalTwoFA:                c.Bool("skip-local-2fa"),
+		Scopes:                        c.StringSlice("scopes"),
+		RequiredClaimName:             c.String("required-claim-name"),
+		RequiredClaimValue:            c.String("required-claim-value"),
+		GroupClaimName:                c.String("group-claim-name"),
+		AdminGroup:                    c.String("admin-group"),
+		RestrictedGroup:               c.String("restricted-group"),
+		GroupTeamMap:                  c.String("group-team-map"),
+		GroupTeamMapRemoval:           c.Bool("group-team-map-removal"),
+	}
+}
+
+func runAddOauth(c *cli.Context) error {
+	ctx, cancel := installSignals()
+	defer cancel()
+
+	if err := initDB(ctx); err != nil {
+		return err
+	}
+
+	config := parseOAuth2Config(c)
+	if config.Provider == "openidConnect" {
+		discoveryURL, err := url.Parse(config.OpenIDConnectAutoDiscoveryURL)
+		if err != nil || (discoveryURL.Scheme != "http" && discoveryURL.Scheme != "https") {
+			return fmt.Errorf("invalid Auto Discovery URL: %s (this must be a valid URL starting with http:// or https://)", config.OpenIDConnectAutoDiscoveryURL)
+		}
+	}
+
+	return auth_model.CreateSource(&auth_model.Source{
+		Type:     auth_model.OAuth2,
+		Name:     c.String("name"),
+		IsActive: true,
+		Cfg:      config,
+	})
+}
+
+func runUpdateOauth(c *cli.Context) error {
+	if !c.IsSet("id") {
+		return fmt.Errorf("--id flag is missing")
+	}
+
+	ctx, cancel := installSignals()
+	defer cancel()
+
+	if err := initDB(ctx); err != nil {
+		return err
+	}
+
+	source, err := auth_model.GetSourceByID(c.Int64("id"))
+	if err != nil {
+		return err
+	}
+
+	oAuth2Config := source.Cfg.(*oauth2.Source)
+
+	if c.IsSet("name") {
+		source.Name = c.String("name")
+	}
+
+	if c.IsSet("provider") {
+		oAuth2Config.Provider = c.String("provider")
+	}
+
+	if c.IsSet("key") {
+		oAuth2Config.ClientID = c.String("key")
+	}
+
+	if c.IsSet("secret") {
+		oAuth2Config.ClientSecret = c.String("secret")
+	}
+
+	if c.IsSet("auto-discover-url") {
+		oAuth2Config.OpenIDConnectAutoDiscoveryURL = c.String("auto-discover-url")
+	}
+
+	if c.IsSet("icon-url") {
+		oAuth2Config.IconURL = c.String("icon-url")
+	}
+
+	if c.IsSet("scopes") {
+		oAuth2Config.Scopes = c.StringSlice("scopes")
+	}
+
+	if c.IsSet("required-claim-name") {
+		oAuth2Config.RequiredClaimName = c.String("required-claim-name")
+	}
+	if c.IsSet("required-claim-value") {
+		oAuth2Config.RequiredClaimValue = c.String("required-claim-value")
+	}
+
+	if c.IsSet("group-claim-name") {
+		oAuth2Config.GroupClaimName = c.String("group-claim-name")
+	}
+	if c.IsSet("admin-group") {
+		oAuth2Config.AdminGroup = c.String("admin-group")
+	}
+	if c.IsSet("restricted-group") {
+		oAuth2Config.RestrictedGroup = c.String("restricted-group")
+	}
+	if c.IsSet("group-team-map") {
+		oAuth2Config.GroupTeamMap = c.String("group-team-map")
+	}
+	if c.IsSet("group-team-map-removal") {
+		oAuth2Config.GroupTeamMapRemoval = c.Bool("group-team-map-removal")
+	}
+
+	// update custom URL mapping
+	customURLMapping := &oauth2.CustomURLMapping{}
+
+	if oAuth2Config.CustomURLMapping != nil {
+		customURLMapping.TokenURL = oAuth2Config.CustomURLMapping.TokenURL
+		customURLMapping.AuthURL = oAuth2Config.CustomURLMapping.AuthURL
+		customURLMapping.ProfileURL = oAuth2Config.CustomURLMapping.ProfileURL
+		customURLMapping.EmailURL = oAuth2Config.CustomURLMapping.EmailURL
+		customURLMapping.Tenant = oAuth2Config.CustomURLMapping.Tenant
+	}
+	if c.IsSet("use-custom-urls") && c.IsSet("custom-token-url") {
+		customURLMapping.TokenURL = c.String("custom-token-url")
+	}
+
+	if c.IsSet("use-custom-urls") && c.IsSet("custom-auth-url") {
+		customURLMapping.AuthURL = c.String("custom-auth-url")
+	}
+
+	if c.IsSet("use-custom-urls") && c.IsSet("custom-profile-url") {
+		customURLMapping.ProfileURL = c.String("custom-profile-url")
+	}
+
+	if c.IsSet("use-custom-urls") && c.IsSet("custom-email-url") {
+		customURLMapping.EmailURL = c.String("custom-email-url")
+	}
+
+	if c.IsSet("use-custom-urls") && c.IsSet("custom-tenant-id") {
+		customURLMapping.Tenant = c.String("custom-tenant-id")
+	}
+
+	oAuth2Config.CustomURLMapping = customURLMapping
+	source.Cfg = oAuth2Config
+
+	return auth_model.UpdateSource(source)
+}
diff --git a/cmd/admin_auth_stmp.go b/cmd/admin_auth_stmp.go
new file mode 100644
index 0000000000..8c65de8a1b
--- /dev/null
+++ b/cmd/admin_auth_stmp.go
@@ -0,0 +1,201 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package cmd
+
+import (
+	"errors"
+	"fmt"
+	"strings"
+
+	auth_model "code.gitea.io/gitea/models/auth"
+	"code.gitea.io/gitea/modules/util"
+	"code.gitea.io/gitea/services/auth/source/smtp"
+
+	"github.com/urfave/cli/v2"
+)
+
+var (
+	smtpCLIFlags = []cli.Flag{
+		&cli.StringFlag{
+			Name:  "name",
+			Value: "",
+			Usage: "Application Name",
+		},
+		&cli.StringFlag{
+			Name:  "auth-type",
+			Value: "PLAIN",
+			Usage: "SMTP Authentication Type (PLAIN/LOGIN/CRAM-MD5) default PLAIN",
+		},
+		&cli.StringFlag{
+			Name:  "host",
+			Value: "",
+			Usage: "SMTP Host",
+		},
+		&cli.IntFlag{
+			Name:  "port",
+			Usage: "SMTP Port",
+		},
+		&cli.BoolFlag{
+			Name:  "force-smtps",
+			Usage: "SMTPS is always used on port 465. Set this to force SMTPS on other ports.",
+			Value: true,
+		},
+		&cli.BoolFlag{
+			Name:  "skip-verify",
+			Usage: "Skip TLS verify.",
+			Value: true,
+		},
+		&cli.StringFlag{
+			Name:  "helo-hostname",
+			Value: "",
+			Usage: "Hostname sent with HELO. Leave blank to send current hostname",
+		},
+		&cli.BoolFlag{
+			Name:  "disable-helo",
+			Usage: "Disable SMTP helo.",
+			Value: true,
+		},
+		&cli.StringFlag{
+			Name:  "allowed-domains",
+			Value: "",
+			Usage: "Leave empty to allow all domains. Separate multiple domains with a comma (',')",
+		},
+		&cli.BoolFlag{
+			Name:  "skip-local-2fa",
+			Usage: "Skip 2FA to log on.",
+			Value: true,
+		},
+		&cli.BoolFlag{
+			Name:  "active",
+			Usage: "This Authentication Source is Activated.",
+			Value: true,
+		},
+	}
+
+	microcmdAuthAddSMTP = &cli.Command{
+		Name:   "add-smtp",
+		Usage:  "Add new SMTP authentication source",
+		Action: runAddSMTP,
+		Flags:  smtpCLIFlags,
+	}
+
+	microcmdAuthUpdateSMTP = &cli.Command{
+		Name:   "update-smtp",
+		Usage:  "Update existing SMTP authentication source",
+		Action: runUpdateSMTP,
+		Flags:  append(smtpCLIFlags[:1], append([]cli.Flag{idFlag}, smtpCLIFlags[1:]...)...),
+	}
+)
+
+func parseSMTPConfig(c *cli.Context, conf *smtp.Source) error {
+	if c.IsSet("auth-type") {
+		conf.Auth = c.String("auth-type")
+		validAuthTypes := []string{"PLAIN", "LOGIN", "CRAM-MD5"}
+		if !util.SliceContainsString(validAuthTypes, strings.ToUpper(c.String("auth-type"))) {
+			return errors.New("Auth must be one of PLAIN/LOGIN/CRAM-MD5")
+		}
+		conf.Auth = c.String("auth-type")
+	}
+	if c.IsSet("host") {
+		conf.Host = c.String("host")
+	}
+	if c.IsSet("port") {
+		conf.Port = c.Int("port")
+	}
+	if c.IsSet("allowed-domains") {
+		conf.AllowedDomains = c.String("allowed-domains")
+	}
+	if c.IsSet("force-smtps") {
+		conf.ForceSMTPS = c.Bool("force-smtps")
+	}
+	if c.IsSet("skip-verify") {
+		conf.SkipVerify = c.Bool("skip-verify")
+	}
+	if c.IsSet("helo-hostname") {
+		conf.HeloHostname = c.String("helo-hostname")
+	}
+	if c.IsSet("disable-helo") {
+		conf.DisableHelo = c.Bool("disable-helo")
+	}
+	if c.IsSet("skip-local-2fa") {
+		conf.SkipLocalTwoFA = c.Bool("skip-local-2fa")
+	}
+	return nil
+}
+
+func runAddSMTP(c *cli.Context) error {
+	ctx, cancel := installSignals()
+	defer cancel()
+
+	if err := initDB(ctx); err != nil {
+		return err
+	}
+
+	if !c.IsSet("name") || len(c.String("name")) == 0 {
+		return errors.New("name must be set")
+	}
+	if !c.IsSet("host") || len(c.String("host")) == 0 {
+		return errors.New("host must be set")
+	}
+	if !c.IsSet("port") {
+		return errors.New("port must be set")
+	}
+	active := true
+	if c.IsSet("active") {
+		active = c.Bool("active")
+	}
+
+	var smtpConfig smtp.Source
+	if err := parseSMTPConfig(c, &smtpConfig); err != nil {
+		return err
+	}
+
+	// If not set default to PLAIN
+	if len(smtpConfig.Auth) == 0 {
+		smtpConfig.Auth = "PLAIN"
+	}
+
+	return auth_model.CreateSource(&auth_model.Source{
+		Type:     auth_model.SMTP,
+		Name:     c.String("name"),
+		IsActive: active,
+		Cfg:      &smtpConfig,
+	})
+}
+
+func runUpdateSMTP(c *cli.Context) error {
+	if !c.IsSet("id") {
+		return fmt.Errorf("--id flag is missing")
+	}
+
+	ctx, cancel := installSignals()
+	defer cancel()
+
+	if err := initDB(ctx); err != nil {
+		return err
+	}
+
+	source, err := auth_model.GetSourceByID(c.Int64("id"))
+	if err != nil {
+		return err
+	}
+
+	smtpConfig := source.Cfg.(*smtp.Source)
+
+	if err := parseSMTPConfig(c, smtpConfig); err != nil {
+		return err
+	}
+
+	if c.IsSet("name") {
+		source.Name = c.String("name")
+	}
+
+	if c.IsSet("active") {
+		source.IsActive = c.Bool("active")
+	}
+
+	source.Cfg = smtpConfig
+
+	return auth_model.UpdateSource(source)
+}
diff --git a/cmd/admin_regenerate.go b/cmd/admin_regenerate.go
new file mode 100644
index 0000000000..0db505ff9c
--- /dev/null
+++ b/cmd/admin_regenerate.go
@@ -0,0 +1,46 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package cmd
+
+import (
+	asymkey_model "code.gitea.io/gitea/models/asymkey"
+	"code.gitea.io/gitea/modules/graceful"
+	repo_service "code.gitea.io/gitea/services/repository"
+
+	"github.com/urfave/cli/v2"
+)
+
+var (
+	microcmdRegenHooks = &cli.Command{
+		Name:   "hooks",
+		Usage:  "Regenerate git-hooks",
+		Action: runRegenerateHooks,
+	}
+
+	microcmdRegenKeys = &cli.Command{
+		Name:   "keys",
+		Usage:  "Regenerate authorized_keys file",
+		Action: runRegenerateKeys,
+	}
+)
+
+func runRegenerateHooks(_ *cli.Context) error {
+	ctx, cancel := installSignals()
+	defer cancel()
+
+	if err := initDB(ctx); err != nil {
+		return err
+	}
+	return repo_service.SyncRepositoryHooks(graceful.GetManager().ShutdownContext())
+}
+
+func runRegenerateKeys(_ *cli.Context) error {
+	ctx, cancel := installSignals()
+	defer cancel()
+
+	if err := initDB(ctx); err != nil {
+		return err
+	}
+	return asymkey_model.RewriteAllPublicKeys(ctx)
+}