diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index 94c15a60d9c60..cfaf91cddb7f2 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -544,6 +544,11 @@ ENABLE = true ;; ;; Maximum length of oauth2 token/cookie stored on server ;MAX_TOKEN_LENGTH = 32767 +;; +;; Pre-register OAuth2 applications for some universally useful services +;; * https://github.com/hickford/git-credential-oauth +;; * https://github.com/git-ecosystem/git-credential-manager +;DEFAULT_APPLICATIONS = git-credential-oauth, git-credential-manager ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/docs/content/administration/config-cheat-sheet.en-us.md b/docs/content/administration/config-cheat-sheet.en-us.md index 30751bf0711d2..71ae4f2e30bd2 100644 --- a/docs/content/administration/config-cheat-sheet.en-us.md +++ b/docs/content/administration/config-cheat-sheet.en-us.md @@ -1100,6 +1100,7 @@ This section only does "set" config, a removed config key from this section won' - `JWT_SECRET_URI`: **_empty_**: Instead of defining JWT_SECRET in the configuration, this configuration option can be used to give Gitea a path to a file that contains the secret (example value: `file:/etc/gitea/oauth2_jwt_secret`) - `JWT_SIGNING_PRIVATE_KEY_FILE`: **jwt/private.pem**: Private key file path used to sign OAuth2 tokens. The path is relative to `APP_DATA_PATH`. This setting is only needed if `JWT_SIGNING_ALGORITHM` is set to `RS256`, `RS384`, `RS512`, `ES256`, `ES384` or `ES512`. The file must contain a RSA or ECDSA private key in the PKCS8 format. If no key exists a 4096 bit key will be created for you. - `MAX_TOKEN_LENGTH`: **32767**: Maximum length of token/cookie to accept from OAuth2 provider +- `DEFAULT_APPLICATIONS`: **git-credential-oauth, git-credential-manager**: Pre-register OAuth applications for some services on startup. See the [OAuth2 documentation](/development/oauth2-provider.md) for the list of available options. ## i18n (`i18n`) diff --git a/docs/content/development/oauth2-provider.en-us.md b/docs/content/development/oauth2-provider.en-us.md index b3824f4b2eacb..81fc04bdcf1d0 100644 --- a/docs/content/development/oauth2-provider.en-us.md +++ b/docs/content/development/oauth2-provider.en-us.md @@ -78,6 +78,17 @@ Gitea token scopes are as follows: | **read:user** | Grants read access to user operations, such as getting user repo subscriptions and user settings. | | **write:user** | Grants read/write/delete access to user operations, such as updating user repo subscriptions, followed users, and user settings. | +## Pre-configured Applications + +Gitea creates OAuth applications for the following services by default on startup, as we assume that these are universally useful. + +|Application|Description|Client ID| +|-----------|-----------|---------| +|[git-credential-oauth](https://github.com/hickford/git-credential-oauth)|Git credential helper|`a4792ccc-144e-407e-86c9-5e7d8d9c3269`| +|[Git Credential Manager](https://github.com/git-ecosystem/git-credential-manager)|Git credential helper|`e90ee53c-94e2-48ac-9358-a874fb9e0662`| + +To prevent unexpected behavior, they are being displayed as locked in the UI and their creation can instead be controlled by the `DEFAULT_APPLICATIONS` parameter in `app.ini`. + ## Client types Gitea supports both confidential and public client types, [as defined by RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749#section-2.1). diff --git a/models/auth/oauth2.go b/models/auth/oauth2.go index 0f64b56c1635b..1b6d68879a97e 100644 --- a/models/auth/oauth2.go +++ b/models/auth/oauth2.go @@ -13,6 +13,8 @@ import ( "strings" "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/container" + "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" @@ -46,6 +48,83 @@ func init() { db.RegisterModel(new(OAuth2Grant)) } +type BuiltinOAuth2Application struct { + ConfigName string + DisplayName string + RedirectURIs []string +} + +func BuiltinApplications() map[string]*BuiltinOAuth2Application { + m := make(map[string]*BuiltinOAuth2Application) + m["a4792ccc-144e-407e-86c9-5e7d8d9c3269"] = &BuiltinOAuth2Application{ + ConfigName: "git-credential-oauth", + DisplayName: "git-credential-oauth", + RedirectURIs: []string{"http://127.0.0.1", "https://127.0.0.1"}, + } + m["e90ee53c-94e2-48ac-9358-a874fb9e0662"] = &BuiltinOAuth2Application{ + ConfigName: "git-credential-manager", + DisplayName: "Git Credential Manager", + RedirectURIs: []string{"http://127.0.0.1", "https://127.0.0.1"}, + } + return m +} + +func Init(ctx context.Context) error { + builtinApps := BuiltinApplications() + var builtinAllClientIDs []string + for clientID := range builtinApps { + builtinAllClientIDs = append(builtinAllClientIDs, clientID) + } + + var registeredApps []*OAuth2Application + if err := db.GetEngine(ctx).In("client_id", builtinAllClientIDs).Find(®isteredApps); err != nil { + return err + } + + clientIDsToAdd := container.Set[string]{} + for _, configName := range setting.OAuth2.DefaultApplications { + found := false + for clientID, builtinApp := range builtinApps { + if builtinApp.ConfigName == configName { + clientIDsToAdd.Add(clientID) // add all user-configured apps to the "add" list + found = true + } + } + if !found { + return fmt.Errorf("unknown oauth2 application: %q", configName) + } + } + clientIDsToDelete := container.Set[string]{} + for _, app := range registeredApps { + if !clientIDsToAdd.Contains(app.ClientID) { + clientIDsToDelete.Add(app.ClientID) // if a registered app is not in the "add" list, it should be deleted + } + } + for _, app := range registeredApps { + clientIDsToAdd.Remove(app.ClientID) // no need to re-add existing (registered) apps, so remove them from the set + } + + for _, app := range registeredApps { + if clientIDsToDelete.Contains(app.ClientID) { + if err := deleteOAuth2Application(ctx, app.ID, 0); err != nil { + return err + } + } + } + for clientID := range clientIDsToAdd { + builtinApp := builtinApps[clientID] + if err := db.Insert(ctx, &OAuth2Application{ + Name: builtinApp.DisplayName, + ClientID: clientID, + RedirectURIs: builtinApp.RedirectURIs, + }); err != nil { + return err + } + } + + return nil +} + // TableName sets the table name to `oauth2_application` func (app *OAuth2Application) TableName() string { return "oauth2_application" @@ -205,6 +284,10 @@ func UpdateOAuth2Application(opts UpdateOAuth2ApplicationOptions) (*OAuth2Applic if app.UID != opts.UserID { return nil, fmt.Errorf("UID mismatch") } + builtinApps := BuiltinApplications() + if _, builtin := builtinApps[app.ClientID]; builtin { + return nil, fmt.Errorf("failed to edit OAuth2 application: application is locked: %s", app.ClientID) + } app.Name = opts.Name app.RedirectURIs = opts.RedirectURIs @@ -261,6 +344,14 @@ func DeleteOAuth2Application(id, userid int64) error { return err } defer committer.Close() + app, err := GetOAuth2ApplicationByID(ctx, id) + if err != nil { + return err + } + builtinApps := BuiltinApplications() + if _, builtin := builtinApps[app.ClientID]; builtin { + return fmt.Errorf("failed to delete OAuth2 application: application is locked: %s", app.ClientID) + } if err := deleteOAuth2Application(ctx, id, userid); err != nil { return err } diff --git a/modules/context/base.go b/modules/context/base.go index 83e5a214f8b3c..8df1dde866d5b 100644 --- a/modules/context/base.go +++ b/modules/context/base.go @@ -147,6 +147,10 @@ func (b *Base) Params(p string) string { return s } +func (b *Base) PathParamRaw(p string) string { + return chi.URLParam(b.Req, strings.TrimPrefix(p, ":")) +} + // ParamsInt64 returns the param on route as int64 func (b *Base) ParamsInt64(p string) int64 { v, _ := strconv.ParseInt(b.Params(p), 10, 64) diff --git a/modules/markup/common/footnote.go b/modules/markup/common/footnote.go index 0e75e2adfd272..4406803694fca 100644 --- a/modules/markup/common/footnote.go +++ b/modules/markup/common/footnote.go @@ -29,17 +29,12 @@ func CleanValue(value []byte) []byte { value = bytes.TrimSpace(value) rs := bytes.Runes(value) result := make([]rune, 0, len(rs)) - needsDash := false for _, r := range rs { - switch { - case unicode.IsLetter(r) || unicode.IsNumber(r) || r == '_': - if needsDash && len(result) > 0 { - result = append(result, '-') - } - needsDash = false + if unicode.IsLetter(r) || unicode.IsNumber(r) || r == '_' || r == '-' { result = append(result, unicode.ToLower(r)) - default: - needsDash = true + } + if unicode.IsSpace(r) { + result = append(result, '-') } } return []byte(string(result)) diff --git a/modules/markup/common/footnote_test.go b/modules/markup/common/footnote_test.go new file mode 100644 index 0000000000000..2327a7b14b23a --- /dev/null +++ b/modules/markup/common/footnote_test.go @@ -0,0 +1,60 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT +package common + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCleanValue(t *testing.T) { + tests := []struct { + param string + expect string + }{ + // Github behavior test cases + {"", ""}, + {"test(0)", "test0"}, + {"test!1", "test1"}, + {"test:2", "test2"}, + {"test*3", "test3"}, + {"test!4", "test4"}, + {"test:5", "test5"}, + {"test*6", "test6"}, + {"test:6 a", "test6-a"}, + {"test:6 !b", "test6-b"}, + {"test:ad # df", "testad--df"}, + {"test:ad #23 df 2*/*", "testad-23-df-2"}, + {"test:ad 23 df 2*/*", "testad-23-df-2"}, + {"test:ad # 23 df 2*/*", "testad--23-df-2"}, + {"Anchors in Markdown", "anchors-in-markdown"}, + {"a_b_c", "a_b_c"}, + {"a-b-c", "a-b-c"}, + {"a-b-c----", "a-b-c----"}, + {"test:6a", "test6a"}, + {"test:a6", "testa6"}, + {"tes a a a a", "tes-a-a---a--a"}, + {" tes a a a a ", "tes-a-a---a--a"}, + {"Header with \"double quotes\"", "header-with-double-quotes"}, + {"Placeholder to force scrolling on link's click", "placeholder-to-force-scrolling-on-links-click"}, + {"tes()", "tes"}, + {"tes(0)", "tes0"}, + {"tes{0}", "tes0"}, + {"tes[0]", "tes0"}, + {"test【0】", "test0"}, + {"tes…@a", "tesa"}, + {"tes¥& a", "tes-a"}, + {"tes= a", "tes-a"}, + {"tes|a", "tesa"}, + {"tes\\a", "tesa"}, + {"tes/a", "tesa"}, + {"a啊啊b", "a啊啊b"}, + {"c🤔️🤔️d", "cd"}, + {"a⚡a", "aa"}, + {"e.~f", "ef"}, + } + for _, test := range tests { + assert.Equal(t, []byte(test.expect), CleanValue([]byte(test.param)), test.param) + } +} diff --git a/modules/setting/oauth2.go b/modules/setting/oauth2.go index 78a9462de9a65..b88070681a7d8 100644 --- a/modules/setting/oauth2.go +++ b/modules/setting/oauth2.go @@ -100,6 +100,7 @@ var OAuth2 = struct { JWTSecretBase64 string `ini:"JWT_SECRET"` JWTSigningPrivateKeyFile string `ini:"JWT_SIGNING_PRIVATE_KEY_FILE"` MaxTokenLength int + DefaultApplications []string }{ Enable: true, AccessTokenExpirationTime: 3600, @@ -108,6 +109,7 @@ var OAuth2 = struct { JWTSigningAlgorithm: "RS256", JWTSigningPrivateKeyFile: "jwt/private.pem", MaxTokenLength: math.MaxInt16, + DefaultApplications: []string{"git-credential-oauth", "git-credential-manager"}, } func loadOAuth2From(rootCfg ConfigProvider) { diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 7697c2a61cc55..2023dade3fa46 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -93,6 +93,7 @@ edit = Edit enabled = Enabled disabled = Disabled +locked = Locked copy = Copy copy_url = Copy URL @@ -850,6 +851,7 @@ oauth2_client_secret_hint = The secret will not be shown again after you leave o oauth2_application_edit = Edit oauth2_application_create_description = OAuth2 applications gives your third-party application access to user accounts on this instance. oauth2_application_remove_description = Removing an OAuth2 application will prevent it from accessing authorized user accounts on this instance. Continue? +oauth2_application_locked = Gitea pre-registers some OAuth2 applications on startup if enabled in config. To prevent unexpected bahavior, these can neither be edited nor removed. Please refer to the OAuth2 documentation for more information. authorized_oauth2_applications = Authorized OAuth2 Applications authorized_oauth2_applications_description = You have granted access to your personal Gitea account to these third party applications. Please revoke access for applications you no longer need. diff --git a/routers/api/v1/repo/wiki.go b/routers/api/v1/repo/wiki.go index e33790a3789a9..7f3a7d06740cb 100644 --- a/routers/api/v1/repo/wiki.go +++ b/routers/api/v1/repo/wiki.go @@ -127,7 +127,7 @@ func EditWikiPage(ctx *context.APIContext) { form := web.GetForm(ctx).(*api.CreateWikiPageOptions) - oldWikiName := wiki_service.WebPathFromRequest(ctx.Params(":pageName")) + oldWikiName := wiki_service.WebPathFromRequest(ctx.PathParamRaw(":pageName")) newWikiName := wiki_service.UserTitleToWebPath("", form.Title) if len(newWikiName) == 0 { @@ -231,7 +231,7 @@ func DeleteWikiPage(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" - wikiName := wiki_service.WebPathFromRequest(ctx.Params(":pageName")) + wikiName := wiki_service.WebPathFromRequest(ctx.PathParamRaw(":pageName")) if err := wiki_service.DeleteWikiPage(ctx, ctx.Doer, ctx.Repo.Repository, wikiName); err != nil { if err.Error() == "file does not exist" { @@ -359,7 +359,7 @@ func GetWikiPage(ctx *context.APIContext) { // "$ref": "#/responses/notFound" // get requested pagename - pageName := wiki_service.WebPathFromRequest(ctx.Params(":pageName")) + pageName := wiki_service.WebPathFromRequest(ctx.PathParamRaw(":pageName")) wikiPage := getWikiPage(ctx, pageName) if !ctx.Written() { @@ -409,7 +409,7 @@ func ListPageRevisions(ctx *context.APIContext) { } // get requested pagename - pageName := wiki_service.WebPathFromRequest(ctx.Params(":pageName")) + pageName := wiki_service.WebPathFromRequest(ctx.PathParamRaw(":pageName")) if len(pageName) == 0 { pageName = "Home" } diff --git a/routers/init.go b/routers/init.go index ddbabcc397447..020fff31c0e38 100644 --- a/routers/init.go +++ b/routers/init.go @@ -10,6 +10,7 @@ import ( "code.gitea.io/gitea/models" asymkey_model "code.gitea.io/gitea/models/asymkey" + authmodel "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/modules/cache" "code.gitea.io/gitea/modules/eventsource" "code.gitea.io/gitea/modules/git" @@ -138,6 +139,7 @@ func InitWebInstalled(ctx context.Context) { mustInit(oauth2.Init) mustInitCtx(ctx, models.Init) + mustInitCtx(ctx, authmodel.Init) mustInit(repo_service.Init) // Booting long running goroutines. diff --git a/routers/web/admin/applications.go b/routers/web/admin/applications.go index 7b27524340374..b26912db48214 100644 --- a/routers/web/admin/applications.go +++ b/routers/web/admin/applications.go @@ -39,7 +39,7 @@ func Applications(ctx *context.Context) { return } ctx.Data["Applications"] = apps - + ctx.Data["BuiltinApplications"] = auth.BuiltinApplications() ctx.HTML(http.StatusOK, tplSettingsApplications) } diff --git a/routers/web/repo/http.go b/routers/web/repo/http.go index 0cae9aeda4549..c8ecb3b1d8ad7 100644 --- a/routers/web/repo/http.go +++ b/routers/web/repo/http.go @@ -147,7 +147,7 @@ func httpBase(ctx *context.Context) *serviceHandler { // rely on the results of Contexter if !ctx.IsSigned { // TODO: support digit auth - which would be Authorization header with digit - ctx.Resp.Header().Set("WWW-Authenticate", "Basic realm=\".\"") + ctx.Resp.Header().Set("WWW-Authenticate", `Basic realm="Gitea"`) ctx.Error(http.StatusUnauthorized) return nil } diff --git a/routers/web/repo/wiki.go b/routers/web/repo/wiki.go index e3c187c33bfdb..4de24e2a3898d 100644 --- a/routers/web/repo/wiki.go +++ b/routers/web/repo/wiki.go @@ -185,7 +185,7 @@ func renderViewPage(ctx *context.Context) (*git.Repository, *git.TreeEntry) { ctx.Data["Pages"] = pages // get requested page name - pageName := wiki_service.WebPathFromRequest(ctx.Params("*")) + pageName := wiki_service.WebPathFromRequest(ctx.PathParamRaw("*")) if len(pageName) == 0 { pageName = "Home" } @@ -332,7 +332,7 @@ func renderRevisionPage(ctx *context.Context) (*git.Repository, *git.TreeEntry) } // get requested pagename - pageName := wiki_service.WebPathFromRequest(ctx.Params("*")) + pageName := wiki_service.WebPathFromRequest(ctx.PathParamRaw("*")) if len(pageName) == 0 { pageName = "Home" } @@ -415,7 +415,7 @@ func renderEditPage(ctx *context.Context) { }() // get requested pagename - pageName := wiki_service.WebPathFromRequest(ctx.Params("*")) + pageName := wiki_service.WebPathFromRequest(ctx.PathParamRaw("*")) if len(pageName) == 0 { pageName = "Home" } @@ -647,7 +647,7 @@ func WikiRaw(ctx *context.Context) { return } - providedWebPath := wiki_service.WebPathFromRequest(ctx.Params("*")) + providedWebPath := wiki_service.WebPathFromRequest(ctx.PathParamRaw("*")) providedGitPath := wiki_service.WebPathToGitPath(providedWebPath) var entry *git.TreeEntry if commit != nil { @@ -759,7 +759,7 @@ func EditWikiPost(ctx *context.Context) { return } - oldWikiName := wiki_service.WebPathFromRequest(ctx.Params("*")) + oldWikiName := wiki_service.WebPathFromRequest(ctx.PathParamRaw("*")) newWikiName := wiki_service.UserTitleToWebPath("", form.Title) if len(form.Message) == 0 { @@ -778,7 +778,7 @@ func EditWikiPost(ctx *context.Context) { // DeleteWikiPagePost delete wiki page func DeleteWikiPagePost(ctx *context.Context) { - wikiName := wiki_service.WebPathFromRequest(ctx.Params("*")) + wikiName := wiki_service.WebPathFromRequest(ctx.PathParamRaw("*")) if len(wikiName) == 0 { wikiName = "Home" } diff --git a/services/webhook/general.go b/services/webhook/general.go index 1f7d204d1f74c..f53ea31ffad75 100644 --- a/services/webhook/general.go +++ b/services/webhook/general.go @@ -131,6 +131,10 @@ func getPullRequestPayloadInfo(p *api.PullRequestPayload, linkFormatter linkForm case api.HookIssueReviewed: text = fmt.Sprintf("[%s] Pull request reviewed: %s", repoLink, titleLink) attachmentText = p.Review.Content + case api.HookIssueReviewRequested: + text = fmt.Sprintf("[%s] Pull request review requested: %s", repoLink, titleLink) + case api.HookIssueReviewRequestRemoved: + text = fmt.Sprintf("[%s] Pull request review request removed: %s", repoLink, titleLink) } if withSender { text += fmt.Sprintf(" by %s", linkFormatter(setting.AppURL+p.Sender.UserName, p.Sender.UserName)) diff --git a/services/wiki/wiki_path.go b/services/wiki/wiki_path.go index f2600ad8ba6d5..e51d6c630ca34 100644 --- a/services/wiki/wiki_path.go +++ b/services/wiki/wiki_path.go @@ -22,15 +22,16 @@ import ( // - "/wiki/100%25+Free" // - "/wiki/2000-01-02+meeting.-" // - If a segment has a suffix "DashMarker(.-)", it means that there is no dash-space conversion for this segment. -// - If a WebPath is a "*.md" pattern, then use it directly as GitPath, to make users can access the raw file. +// - If a WebPath is a "*.md" pattern, then use the unescaped value directly as GitPath, to make users can access the raw file. // * Git Path (only space doesn't need to be escaped): // - "/.wiki.git/Home-Page.md" // - "/.wiki.git/100%25 Free.md" // - "/.wiki.git/2000-01-02 meeting.-.md" // TODO: support subdirectory in the future // -// Although this package now has the ablity to support subdirectory, but the route package doesn't: +// Although this package now has the ability to support subdirectory, but the route package doesn't: // * Double-escaping problem: the URL "/wiki/abc%2Fdef" becomes "/wiki/abc/def" by ctx.Params, which is incorrect +// * This problem should have been 99% fixed, but it needs more tests. // * The old wiki code's behavior is always using %2F, instead of subdirectory, so there are a lot of legacy "%2F" files in user wikis. type WebPath string @@ -91,7 +92,8 @@ func WebPathSegments(s WebPath) []string { func WebPathToGitPath(s WebPath) string { if strings.HasSuffix(string(s), ".md") { - return string(s) + ret, _ := url.PathUnescape(string(s)) + return util.PathJoinRelX(ret) } a := strings.Split(string(s), "/") @@ -124,7 +126,10 @@ func GitPathToWebPath(s string) (wp WebPath, err error) { func WebPathToUserTitle(s WebPath) (dir, display string) { dir = path.Dir(string(s)) display = path.Base(string(s)) - display = strings.TrimSuffix(display, ".md") + if strings.HasSuffix(display, ".md") { + display = strings.TrimSuffix(display, ".md") + display, _ = url.PathUnescape(display) + } display, _ = unescapeSegment(display) return dir, display } @@ -141,8 +146,7 @@ func WebPathFromRequest(s string) WebPath { } func UserTitleToWebPath(base, title string) WebPath { - // TODO: ctx.Params does un-escaping, so the URL "/wiki/abc%2Fdef" becomes "wiki path = `abc/def`", which is incorrect. - // And the old wiki code's behavior is always using %2F, instead of subdirectory. + // TODO: no support for subdirectory, because the old wiki code's behavior is always using %2F, instead of subdirectory. // So we do not add the support for writing slashes in title at the moment. title = strings.TrimSpace(title) title = util.PathJoinRelX(base, escapeSegToWeb(title, false)) diff --git a/services/wiki/wiki_test.go b/services/wiki/wiki_test.go index f126224244a1c..85d99806fe564 100644 --- a/services/wiki/wiki_test.go +++ b/services/wiki/wiki_test.go @@ -59,6 +59,7 @@ func TestWebPathToDisplayName(t *testing.T) { {"name with / slash", "name-with %2F slash"}, {"name with % percent", "name-with %25 percent"}, {"2000-01-02 meeting", "2000-01-02+meeting.-.md"}, + {"a b", "a%20b.md"}, } { _, displayName := WebPathToUserTitle(test.WebPath) assert.EqualValues(t, test.Expected, displayName) @@ -73,7 +74,8 @@ func TestWebPathToGitPath(t *testing.T) { for _, test := range []test{ {"wiki-name.md", "wiki%20name"}, {"wiki-name.md", "wiki+name"}, - {"wiki%20name.md", "wiki%20name.md"}, + {"wiki name.md", "wiki%20name.md"}, + {"wiki%20name.md", "wiki%2520name.md"}, {"2000-01-02-meeting.md", "2000-01-02+meeting"}, {"2000-01-02 meeting.-.md", "2000-01-02%20meeting.-"}, } { diff --git a/templates/user/settings/applications_oauth2_list.tmpl b/templates/user/settings/applications_oauth2_list.tmpl index be6569c03c03f..1a536e50ac0a7 100644 --- a/templates/user/settings/applications_oauth2_list.tmpl +++ b/templates/user/settings/applications_oauth2_list.tmpl @@ -4,7 +4,7 @@ {{.locale.Tr "settings.oauth2_application_create_description"}} {{range .Applications}} -