diff --git a/.golangci.yml b/.golangci.yml index 60482c415f..2f1587a1e6 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -114,6 +114,10 @@ linters: - stringsbuilder perfsprint: concat-loop: false + govet: + enable: + - nilness + - unusedwrite exclusions: generated: lax presets: diff --git a/Makefile b/Makefile index 4a7e73e582..2b9fc60eb3 100644 --- a/Makefile +++ b/Makefile @@ -32,7 +32,7 @@ XGO_VERSION := go-1.25.x AIR_PACKAGE ?= github.com/air-verse/air@v1 EDITORCONFIG_CHECKER_PACKAGE ?= github.com/editorconfig-checker/editorconfig-checker/v3/cmd/editorconfig-checker@v3 GOFUMPT_PACKAGE ?= mvdan.cc/gofumpt@v0.9.2 -GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.6.0 +GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.7.0 GXZ_PACKAGE ?= github.com/ulikunitz/xz/cmd/gxz@v0.5.15 MISSPELL_PACKAGE ?= github.com/golangci/misspell/cmd/misspell@v0.7.0 SWAGGER_PACKAGE ?= github.com/go-swagger/go-swagger/cmd/swagger@v0.33.1 @@ -40,7 +40,6 @@ XGO_PACKAGE ?= src.techknowlogick.com/xgo@latest GO_LICENSES_PACKAGE ?= github.com/google/go-licenses@v1 GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1 ACTIONLINT_PACKAGE ?= github.com/rhysd/actionlint/cmd/actionlint@v1.7.9 -GOPLS_PACKAGE ?= golang.org/x/tools/gopls@v0.20.0 DOCKER_IMAGE ?= gitea/gitea DOCKER_TAG ?= latest @@ -333,7 +332,7 @@ lint-frontend: lint-js lint-css ## lint frontend files lint-frontend-fix: lint-js-fix lint-css-fix ## lint frontend files and fix issues .PHONY: lint-backend -lint-backend: lint-go lint-go-gitea-vet lint-go-gopls lint-editorconfig ## lint backend files +lint-backend: lint-go lint-go-gitea-vet lint-editorconfig ## lint backend files .PHONY: lint-backend-fix lint-backend-fix: lint-go-fix lint-go-gitea-vet lint-editorconfig ## lint backend files and fix issues @@ -396,11 +395,6 @@ lint-go-gitea-vet: ## lint go files with gitea-vet @echo "Running gitea-vet..." @$(GO) vet -vettool="$(shell GOOS= GOARCH= go tool -n gitea-vet)" ./... -.PHONY: lint-go-gopls -lint-go-gopls: ## lint go files with gopls - @echo "Running gopls check..." - @GO=$(GO) GOPLS_PACKAGE=$(GOPLS_PACKAGE) tools/lint-go-gopls.sh $(GO_SOURCES) - .PHONY: lint-editorconfig lint-editorconfig: @echo "Running editorconfig check..." @@ -844,7 +838,6 @@ deps-tools: ## install tool dependencies $(GO) install $(GO_LICENSES_PACKAGE) & \ $(GO) install $(GOVULNCHECK_PACKAGE) & \ $(GO) install $(ACTIONLINT_PACKAGE) & \ - $(GO) install $(GOPLS_PACKAGE) & \ wait node_modules: pnpm-lock.yaml diff --git a/eslint.config.ts b/eslint.config.ts index c849cdbc62..c2fddc856c 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -205,7 +205,7 @@ export default defineConfig([ '@typescript-eslint/no-non-null-asserted-optional-chain': [2], '@typescript-eslint/no-non-null-assertion': [0], '@typescript-eslint/no-redeclare': [0], - '@typescript-eslint/no-redundant-type-constituents': [0], // rule does not properly work without strickNullChecks + '@typescript-eslint/no-redundant-type-constituents': [2], '@typescript-eslint/no-require-imports': [2], '@typescript-eslint/no-restricted-imports': [0], '@typescript-eslint/no-restricted-types': [0], diff --git a/go.mod b/go.mod index 51cf47b2d3..6806e76ffc 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module code.gitea.io/gitea go 1.25.0 -toolchain go1.25.4 +toolchain go1.25.5 // rfc5280 said: "The serial number is an integer assigned by the CA to each certificate." // But some CAs use negative serial number, just relax the check. related: diff --git a/modules/fileicon/material.go b/modules/fileicon/material.go index 5361592d8a..b8ee13cd8c 100644 --- a/modules/fileicon/material.go +++ b/modules/fileicon/material.go @@ -76,7 +76,7 @@ func (m *MaterialIconProvider) renderFileIconSVG(p *RenderedIconPool, name, svg, if p.IconSVGs[svgID] == "" { p.IconSVGs[svgID] = svgHTML } - return template.HTML(``) + return template.HTML(``) } func (m *MaterialIconProvider) EntryIconHTML(p *RenderedIconPool, entry *EntryInfo) template.HTML { diff --git a/modules/fileicon/render.go b/modules/fileicon/render.go index 8ed86b9ac0..6b2fcfa81e 100644 --- a/modules/fileicon/render.go +++ b/modules/fileicon/render.go @@ -25,7 +25,7 @@ func (p *RenderedIconPool) RenderToHTML() template.HTML { return "" } sb := &strings.Builder{} - sb.WriteString(`
`) + sb.WriteString(`
`) for _, icon := range p.IconSVGs { sb.WriteString(string(icon)) } diff --git a/modules/git/attribute/attribute.go b/modules/git/attribute/attribute.go index 9c01cb339e..8719369df8 100644 --- a/modules/git/attribute/attribute.go +++ b/modules/git/attribute/attribute.go @@ -96,8 +96,8 @@ func (attrs *Attributes) GetGitlabLanguage() optional.Option[string] { // gitlab-language may have additional parameters after the language // ignore them and just use the main language // https://docs.gitlab.com/ee/user/project/highlighting.html#override-syntax-highlighting-for-a-file-type - if idx := strings.IndexByte(raw, '?'); idx >= 0 { - return optional.Some(raw[:idx]) + if before, _, ok := strings.Cut(raw, "?"); ok { + return optional.Some(before) } } return attrStr diff --git a/modules/git/foreachref/parser.go b/modules/git/foreachref/parser.go index ebdc7344d0..fa2ef316c7 100644 --- a/modules/git/foreachref/parser.go +++ b/modules/git/foreachref/parser.go @@ -113,10 +113,10 @@ func (p *Parser) parseRef(refBlock string) (map[string]string, error) { var fieldKey string var fieldVal string - firstSpace := strings.Index(field, " ") - if firstSpace > 0 { - fieldKey = field[:firstSpace] - fieldVal = field[firstSpace+1:] + before, after, ok := strings.Cut(field, " ") + if ok { + fieldKey = before + fieldVal = after } else { // could be the case if the requested field had no value fieldKey = field diff --git a/modules/git/parse.go b/modules/git/parse.go index a7f5c58e89..d4ff0ecb23 100644 --- a/modules/git/parse.go +++ b/modules/git/parse.go @@ -27,15 +27,15 @@ func parseLsTreeLine(line []byte) (*LsTreeEntry, error) { // \t var err error - posTab := bytes.IndexByte(line, '\t') - if posTab == -1 { + before, after, ok := bytes.Cut(line, []byte{'\t'}) + if !ok { return nil, fmt.Errorf("invalid ls-tree output (no tab): %q", line) } entry := new(LsTreeEntry) - entryAttrs := line[:posTab] - entryName := line[posTab+1:] + entryAttrs := before + entryName := after entryMode, entryAttrs, _ := bytes.Cut(entryAttrs, sepSpace) _ /* entryType */, entryAttrs, _ = bytes.Cut(entryAttrs, sepSpace) // the type is not used, the mode is enough to determine the type diff --git a/modules/highlight/highlight.go b/modules/highlight/highlight.go index 77f24fa3f3..77e47fdf48 100644 --- a/modules/highlight/highlight.go +++ b/modules/highlight/highlight.go @@ -77,8 +77,8 @@ func Code(fileName, language, code string) (output template.HTML, lexerName stri if lexer == nil { // Attempt stripping off the '?' - if idx := strings.IndexByte(language, '?'); idx > 0 { - lexer = lexers.Get(language[:idx]) + if before, _, ok := strings.Cut(language, "?"); ok { + lexer = lexers.Get(before) } } } diff --git a/modules/indexer/code/internal/util.go b/modules/indexer/code/internal/util.go index fa958be473..5d62a5ccb9 100644 --- a/modules/indexer/code/internal/util.go +++ b/modules/indexer/code/internal/util.go @@ -17,20 +17,20 @@ func FilenameIndexerID(repoID int64, filename string) string { } func ParseIndexerID(indexerID string) (int64, string) { - index := strings.IndexByte(indexerID, '_') - if index == -1 { + before, after, ok := strings.Cut(indexerID, "_") + if !ok { log.Error("Unexpected ID in repo indexer: %s", indexerID) } - repoID, _ := internal.ParseBase36(indexerID[:index]) - return repoID, indexerID[index+1:] + repoID, _ := internal.ParseBase36(before) + return repoID, after } func FilenameOfIndexerID(indexerID string) string { - index := strings.IndexByte(indexerID, '_') - if index == -1 { + _, after, ok := strings.Cut(indexerID, "_") + if !ok { log.Error("Unexpected ID in repo indexer: %s", indexerID) } - return indexerID[index+1:] + return after } // FilenameMatchIndexPos returns the boundaries of its first seven lines. diff --git a/modules/markup/html_link.go b/modules/markup/html_link.go index 43faef1681..7523ebaed0 100644 --- a/modules/markup/html_link.go +++ b/modules/markup/html_link.go @@ -33,7 +33,7 @@ func shortLinkProcessor(ctx *RenderContext, node *html.Node) { // Of text and link contents sl := strings.SplitSeq(content, "|") for v := range sl { - if equalPos := strings.IndexByte(v, '='); equalPos == -1 { + if found := strings.Contains(v, "="); !found { // There is no equal in this argument; this is a mandatory arg if props["name"] == "" { if IsFullURLString(v) { @@ -55,8 +55,8 @@ func shortLinkProcessor(ctx *RenderContext, node *html.Node) { } else { // There is an equal; optional argument. - sep := strings.IndexByte(v, '=') - key, val := v[:sep], html.UnescapeString(v[sep+1:]) + before, after, _ := strings.Cut(v, "=") + key, val := before, html.UnescapeString(after) // When parsing HTML, x/net/html will change all quotes which are // not used for syntax into UTF-8 quotes. So checking val[0] won't diff --git a/modules/setting/config_env.go b/modules/setting/config_env.go index 8b204e5c7c..4758eb72cb 100644 --- a/modules/setting/config_env.go +++ b/modules/setting/config_env.go @@ -51,10 +51,10 @@ func decodeEnvSectionKey(encoded string) (ok bool, section, key string) { for _, unescapeIdx := range escapeStringIndices { preceding := encoded[last:unescapeIdx[0]] if !inKey { - if splitter := strings.Index(preceding, "__"); splitter > -1 { - section += preceding[:splitter] + if before, after, cutOk := strings.Cut(preceding, "__"); cutOk { + section += before inKey = true - key += preceding[splitter+2:] + key += after } else { section += preceding } @@ -77,9 +77,9 @@ func decodeEnvSectionKey(encoded string) (ok bool, section, key string) { } remaining := encoded[last:] if !inKey { - if splitter := strings.Index(remaining, "__"); splitter > -1 { - section += remaining[:splitter] - key += remaining[splitter+2:] + if before, after, cutOk := strings.Cut(remaining, "__"); cutOk { + section += before + key += after } else { section += remaining } @@ -111,21 +111,21 @@ func decodeEnvironmentKey(prefixGitea, suffixFile, envKey string) (ok bool, sect func EnvironmentToConfig(cfg ConfigProvider, envs []string) (changed bool) { for _, kv := range envs { - idx := strings.IndexByte(kv, '=') - if idx < 0 { + before, after, ok := strings.Cut(kv, "=") + if !ok { continue } // parse the environment variable to config section name and key name - envKey := kv[:idx] - envValue := kv[idx+1:] + envKey := before + envValue := after ok, sectionName, keyName, useFileValue := decodeEnvironmentKey(EnvConfigKeyPrefixGitea, EnvConfigKeySuffixFile, envKey) if !ok { continue } // use environment value as config value, or read the file content as value if the key indicates a file - keyValue := envValue + keyValue := envValue //nolint:staticcheck // false positive if useFileValue { fileContent, err := os.ReadFile(envValue) if err != nil { diff --git a/modules/validation/binding.go b/modules/validation/binding.go index 335f248ead..3ecc532613 100644 --- a/modules/validation/binding.go +++ b/modules/validation/binding.go @@ -215,8 +215,8 @@ func addValidGroupTeamMapRule() { } func portOnly(hostport string) string { - colon := strings.IndexByte(hostport, ':') - if colon == -1 { + _, after, ok := strings.Cut(hostport, ":") + if !ok { return "" } if i := strings.Index(hostport, "]:"); i != -1 { @@ -225,7 +225,7 @@ func portOnly(hostport string) string { if strings.Contains(hostport, "]") { return "" } - return hostport[colon+len(":"):] + return after } func validPort(p string) bool { diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index b5b90b31a5..6712250924 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -1354,8 +1354,11 @@ editor.this_file_locked = File is locked editor.must_be_on_a_branch = You must be on a branch to make or propose changes to this file. editor.fork_before_edit = You must fork this repository to make or propose changes to this file. editor.delete_this_file = Delete File +editor.delete_this_directory = Delete Directory editor.must_have_write_access = You must have write access to make or propose changes to this file. editor.file_delete_success = File "%s" has been deleted. +editor.directory_delete_success = Directory "%s" has been deleted. +editor.delete_directory = Delete directory '%s' editor.name_your_file = Name your file… editor.filename_help = Add a directory by typing its name followed by a slash ('/'). Remove a directory by typing backspace at the beginning of the input field. editor.or = or diff --git a/options/locale/locale_ga-IE.ini b/options/locale/locale_ga-IE.ini index abda334ff5..6b9ae41e9b 100644 --- a/options/locale/locale_ga-IE.ini +++ b/options/locale/locale_ga-IE.ini @@ -1354,8 +1354,11 @@ editor.this_file_locked=Tá an comhad faoi ghlas editor.must_be_on_a_branch=Caithfidh tú a bheith ar bhrainse chun athruithe a dhéanamh nó a mholadh ar an gcomhad seo. editor.fork_before_edit=Ní mór duit an stór seo a fhorcáil chun athruithe a dhéanamh nó a mholadh ar an gcomhad seo. editor.delete_this_file=Scrios Comhad +editor.delete_this_directory=Scrios Eolaire editor.must_have_write_access=Caithfidh rochtain scríofa a bheith agat chun athruithe a dhéanamh nó a mholadh ar an gcomhad seo. editor.file_delete_success=Tá an comhad "%s" scriosta. +editor.directory_delete_success=Scriosadh an eolaire "%s". +editor.delete_directory=Scrios an eolaire '%s' editor.name_your_file=Ainmnigh do chomhad… editor.filename_help=Cuir eolaire leis trína ainm a chlóscríobh ina dhiaidh sin le slash ('/'). Bain eolaire trí backspace a chlóscríobh ag tús an réimse ionchuir. editor.or=nó @@ -1482,6 +1485,7 @@ projects.column.new_submit=Cruthaigh Colún projects.column.new=Colún Nua projects.column.set_default=Socraigh Réamhshocrú projects.column.set_default_desc=Socraigh an colún seo mar réamhshocrú le haghaidh saincheisteanna agus tarraingtí gan chatagóir +projects.column.default_column_hint=Cuirfear saincheisteanna nua a chuirtear leis an tionscadal seo leis an gcolún seo projects.column.delete=Scrios Colún projects.column.deletion_desc=Ag scriosadh colún tionscadail aistríonn gach saincheist ghaolmhar chuig an gcolún. Lean ar aghaidh? projects.column.color=Dath @@ -3038,6 +3042,7 @@ dashboard.update_migration_poster_id=Nuashonraigh ID póstaer imir dashboard.git_gc_repos=Bailitheoir bruscair gach stórais dashboard.resync_all_sshkeys=Nuashonraigh an comhad '.ssh/authorized_keys' le heochracha SSH Gitea dashboard.resync_all_sshprincipals=Nuashonraigh an comhad '.ssh/authorized_principals' le príomhoidí SSH Gitea +dashboard.resync_all_hooks=Athshioncrónaigh crúcaí git na stórtha uile (réamhghlacadh, nuashonrú, iarghlacadh, próiseasghlacadh, ...) dashboard.reinit_missing_repos=Aththosaigh gach stórais Git atá in easnamh a bhfuil taifid ann dóibh dashboard.sync_external_users=Sioncrónaigh sonraí úsáideoirí seachtracha dashboard.cleanup_hook_task_table=Glan suas an tábla hook_task diff --git a/options/locale/locale_ja-JP.ini b/options/locale/locale_ja-JP.ini index 32d7b51ae9..2e28f5a13c 100644 --- a/options/locale/locale_ja-JP.ini +++ b/options/locale/locale_ja-JP.ini @@ -1354,8 +1354,11 @@ editor.this_file_locked=ファイルはロックされています editor.must_be_on_a_branch=このファイルを変更したり変更の提案をするには、ブランチ上にいる必要があります。 editor.fork_before_edit=このファイルを変更したり変更の提案をするには、リポジトリをフォークする必要があります。 editor.delete_this_file=ファイルを削除 +editor.delete_this_directory=ディレクトリを削除 editor.must_have_write_access=このファイルを変更したり変更の提案をするには、書き込み権限が必要です。 editor.file_delete_success=ファイル "%s" を削除しました。 +editor.directory_delete_success=ディレクトリ "%s" を削除しました。 +editor.delete_directory=ディレクトリ'%s'を削除 editor.name_your_file=ファイル名を指定… editor.filename_help=ディレクトリを追加するにはディレクトリ名に続けてスラッシュ('/')を入力します。 ディレクトリを削除するには入力欄の先頭でbackspaceキーを押します。 editor.or=または @@ -1482,6 +1485,7 @@ projects.column.new_submit=列を作成 projects.column.new=新しい列 projects.column.set_default=デフォルトに設定 projects.column.set_default_desc=この列を未分類のイシューやプルリクエストが入るデフォルトの列にします +projects.column.default_column_hint=このプロジェクトに追加された新しいイシューがこの列に追加されます projects.column.delete=列を削除 projects.column.deletion_desc=プロジェクト列を削除すると、関連するすべてのイシューがデフォルトの列に移動します。 続行しますか? projects.column.color=カラー @@ -3038,6 +3042,7 @@ dashboard.update_migration_poster_id=移行する投稿者IDの更新 dashboard.git_gc_repos=すべてのリポジトリでガベージコレクションを実行 dashboard.resync_all_sshkeys='.ssh/authorized_keys' ファイルをGitea上のSSHキーで更新 dashboard.resync_all_sshprincipals='.ssh/authorized_principals' ファイルをGitea上のSSHプリンシパルで更新 +dashboard.resync_all_hooks=すべてのリポジトリのGitフックを再同期する (pre-receive, update, post-receive, proc-receive, ...) dashboard.reinit_missing_repos=レコードが存在するが見当たらないすべてのGitリポジトリを再初期化する dashboard.sync_external_users=外部ユーザーデータの同期 dashboard.cleanup_hook_task_table=hook_taskテーブルのクリーンアップ diff --git a/options/locale/locale_pt-PT.ini b/options/locale/locale_pt-PT.ini index 22e16de93f..0b2e57ea00 100644 --- a/options/locale/locale_pt-PT.ini +++ b/options/locale/locale_pt-PT.ini @@ -1354,8 +1354,11 @@ editor.this_file_locked=Ficheiro bloqueado editor.must_be_on_a_branch=Tem que estar num ramo para fazer ou propor modificações neste ficheiro. editor.fork_before_edit=Tem que fazer uma derivação deste repositório para fazer ou propor modificações neste ficheiro. editor.delete_this_file=Eliminar ficheiro +editor.delete_this_directory=Eliminar pasta editor.must_have_write_access=Tem que ter permissões de escrita para fazer ou propor modificações neste ficheiro. editor.file_delete_success=O ficheiro "%s" foi eliminado. +editor.directory_delete_success=A pasta "%s" foi eliminada. +editor.delete_directory=Eliminar a pasta '%s' editor.name_your_file=Nomeie o seu ficheiro… editor.filename_help=Adicione uma pasta escrevendo o nome dessa pasta seguido de uma barra('/'). Remova uma pasta carregando na tecla de apagar ('←') no início do campo. editor.or=ou diff --git a/routers/api/v1/admin/user.go b/routers/api/v1/admin/user.go index abc4e52877..6f1e2eb120 100644 --- a/routers/api/v1/admin/user.go +++ b/routers/api/v1/admin/user.go @@ -216,9 +216,12 @@ func EditUser(ctx *context.APIContext) { } if form.Email != nil { - if err := user_service.AdminAddOrSetPrimaryEmailAddress(ctx, ctx.ContextUser, *form.Email); err != nil { + if err := user_service.ReplacePrimaryEmailAddress(ctx, ctx.ContextUser, *form.Email); err != nil { switch { case user_model.IsErrEmailCharIsNotSupported(err), user_model.IsErrEmailInvalid(err): + if !user_model.IsEmailDomainAllowed(*form.Email) { + err = fmt.Errorf("the domain of user email %s conflicts with EMAIL_DOMAIN_ALLOWLIST or EMAIL_DOMAIN_BLOCKLIST", *form.Email) + } ctx.APIError(http.StatusBadRequest, err) case user_model.IsErrEmailAlreadyUsed(err): ctx.APIError(http.StatusBadRequest, err) @@ -227,10 +230,6 @@ func EditUser(ctx *context.APIContext) { } return } - - if !user_model.IsEmailDomainAllowed(*form.Email) { - ctx.Resp.Header().Add("X-Gitea-Warning", fmt.Sprintf("the domain of user email %s conflicts with EMAIL_DOMAIN_ALLOWLIST or EMAIL_DOMAIN_BLOCKLIST", *form.Email)) - } } opts := &user_service.UpdateOptions{ diff --git a/routers/api/v1/repo/file.go b/routers/api/v1/repo/file.go index ec34d54d22..27a0827a10 100644 --- a/routers/api/v1/repo/file.go +++ b/routers/api/v1/repo/file.go @@ -610,10 +610,6 @@ func handleChangeRepoFilesError(ctx *context.APIContext, err error) { ctx.APIError(http.StatusUnprocessableEntity, err) return } - if git.IsErrBranchNotExist(err) || files_service.IsErrRepoFileDoesNotExist(err) || git.IsErrNotExist(err) { - ctx.APIError(http.StatusNotFound, err) - return - } if errors.Is(err, util.ErrNotExist) { ctx.APIError(http.StatusNotFound, err) return diff --git a/routers/private/serv.go b/routers/private/serv.go index 3dfe4d21da..b752556c23 100644 --- a/routers/private/serv.go +++ b/routers/private/serv.go @@ -108,21 +108,19 @@ func ServCommand(ctx *context.PrivateContext) { results.RepoName = repoName[:len(repoName)-5] } - // Check if there is a user redirect for the requested owner - redirectedUserID, err := user_model.LookupUserRedirect(ctx, results.OwnerName) - if err == nil { - owner, err := user_model.GetUserByID(ctx, redirectedUserID) - if err == nil { - log.Info("User %s has been redirected to %s", results.OwnerName, owner.Name) - results.OwnerName = owner.Name - } else { - log.Warn("User %s has a redirect to user with ID %d, but no user with this ID could be found. Trying without redirect...", results.OwnerName, redirectedUserID) - } - } - owner, err := user_model.GetUserByName(ctx, results.OwnerName) if err != nil { - if user_model.IsErrUserNotExist(err) { + if !user_model.IsErrUserNotExist(err) { + log.Error("Unable to get repository owner: %s/%s Error: %v", results.OwnerName, results.RepoName, err) + ctx.JSON(http.StatusForbidden, private.Response{ + UserMsg: fmt.Sprintf("Unable to get repository owner: %s/%s %v", results.OwnerName, results.RepoName, err), + }) + return + } + + // Check if there is a user redirect for the requested owner + redirectedUserID, err := user_model.LookupUserRedirect(ctx, results.OwnerName) + if err != nil { // User is fetching/cloning a non-existent repository log.Warn("Failed authentication attempt (cannot find repository: %s/%s) from %s", results.OwnerName, results.RepoName, ctx.RemoteAddr()) ctx.JSON(http.StatusNotFound, private.Response{ @@ -130,11 +128,20 @@ func ServCommand(ctx *context.PrivateContext) { }) return } - log.Error("Unable to get repository owner: %s/%s Error: %v", results.OwnerName, results.RepoName, err) - ctx.JSON(http.StatusForbidden, private.Response{ - UserMsg: fmt.Sprintf("Unable to get repository owner: %s/%s %v", results.OwnerName, results.RepoName, err), - }) - return + + redirectUser, err := user_model.GetUserByID(ctx, redirectedUserID) + if err != nil { + // User is fetching/cloning a non-existent repository + log.Warn("Failed authentication attempt (cannot find repository: %s/%s) from %s", results.OwnerName, results.RepoName, ctx.RemoteAddr()) + ctx.JSON(http.StatusNotFound, private.Response{ + UserMsg: fmt.Sprintf("Cannot find repository: %s/%s", results.OwnerName, results.RepoName), + }) + return + } + + log.Info("User %s has been redirected to %s", results.OwnerName, redirectUser.Name) + results.OwnerName = redirectUser.Name + owner = redirectUser } if !owner.IsOrganization() && !owner.IsActive { ctx.JSON(http.StatusForbidden, private.Response{ @@ -143,24 +150,33 @@ func ServCommand(ctx *context.PrivateContext) { return } - redirectedRepoID, err := repo_model.LookupRedirect(ctx, owner.ID, results.RepoName) - if err == nil { - redirectedRepo, err := repo_model.GetRepositoryByID(ctx, redirectedRepoID) - if err == nil { - log.Info("Repository %s/%s has been redirected to %s/%s", results.OwnerName, results.RepoName, redirectedRepo.OwnerName, redirectedRepo.Name) - results.RepoName = redirectedRepo.Name - results.OwnerName = redirectedRepo.OwnerName - owner.ID = redirectedRepo.OwnerID - } else { - log.Warn("Repo %s/%s has a redirect to repo with ID %d, but no repo with this ID could be found. Trying without redirect...", results.OwnerName, results.RepoName, redirectedRepoID) - } - } - // Now get the Repository and set the results section repoExist := true repo, err := repo_model.GetRepositoryByName(ctx, owner.ID, results.RepoName) if err != nil { - if repo_model.IsErrRepoNotExist(err) { + if !repo_model.IsErrRepoNotExist(err) { + log.Error("Unable to get repository: %s/%s Error: %v", results.OwnerName, results.RepoName, err) + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: fmt.Sprintf("Unable to get repository: %s/%s %v", results.OwnerName, results.RepoName, err), + }) + return + } + + redirectedRepoID, err := repo_model.LookupRedirect(ctx, owner.ID, results.RepoName) + if err == nil { + redirectedRepo, err := repo_model.GetRepositoryByID(ctx, redirectedRepoID) + if err == nil { + log.Info("Repository %s/%s has been redirected to %s/%s", results.OwnerName, results.RepoName, redirectedRepo.OwnerName, redirectedRepo.Name) + results.RepoName = redirectedRepo.Name + results.OwnerName = redirectedRepo.OwnerName + repo = redirectedRepo + owner.ID = redirectedRepo.OwnerID + } else { + log.Warn("Repo %s/%s has a redirect to repo with ID %d, but no repo with this ID could be found. Trying without redirect...", results.OwnerName, results.RepoName, redirectedRepoID) + } + } + + if repo == nil { repoExist = false if mode == perm.AccessModeRead { // User is fetching/cloning a non-existent repository @@ -170,13 +186,6 @@ func ServCommand(ctx *context.PrivateContext) { }) return } - // else fallthrough (push-to-create may kick in below) - } else { - log.Error("Unable to get repository: %s/%s Error: %v", results.OwnerName, results.RepoName, err) - ctx.JSON(http.StatusInternalServerError, private.Response{ - Err: fmt.Sprintf("Unable to get repository: %s/%s %v", results.OwnerName, results.RepoName, err), - }) - return } } diff --git a/routers/web/admin/users.go b/routers/web/admin/users.go index a338936151..ed0eecf90a 100644 --- a/routers/web/admin/users.go +++ b/routers/web/admin/users.go @@ -409,7 +409,7 @@ func EditUserPost(ctx *context.Context) { } if form.Email != "" { - if err := user_service.AdminAddOrSetPrimaryEmailAddress(ctx, u, form.Email); err != nil { + if err := user_service.ReplacePrimaryEmailAddress(ctx, u, form.Email); err != nil { switch { case user_model.IsErrEmailCharIsNotSupported(err), user_model.IsErrEmailInvalid(err): ctx.Data["Err_Email"] = true diff --git a/routers/web/repo/blame.go b/routers/web/repo/blame.go index e304633f95..0eebff6aa8 100644 --- a/routers/web/repo/blame.go +++ b/routers/web/repo/blame.go @@ -10,7 +10,6 @@ import ( "net/url" "path" "strconv" - "strings" repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" @@ -42,8 +41,8 @@ type blameRow struct { // RefBlame render blame page func RefBlame(ctx *context.Context) { - ctx.Data["PageIsViewCode"] = true ctx.Data["IsBlame"] = true + prepareRepoViewContent(ctx, ctx.Repo.RefTypeNameSubURL()) // Get current entry user currently looking at. if ctx.Repo.TreePath == "" { @@ -56,17 +55,6 @@ func RefBlame(ctx *context.Context) { return } - treeNames := strings.Split(ctx.Repo.TreePath, "/") - var paths []string - for i := range treeNames { - paths = append(paths, strings.Join(treeNames[:i+1], "/")) - } - - ctx.Data["Paths"] = paths - ctx.Data["TreeNames"] = treeNames - ctx.Data["BranchLink"] = ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL() - ctx.Data["RawFileLink"] = ctx.Repo.RepoLink + "/raw/" + ctx.Repo.RefTypeNameSubURL() + "/" + util.PathEscapeSegments(ctx.Repo.TreePath) - blob := entry.Blob() fileSize := blob.Size() ctx.Data["FileSize"] = fileSize diff --git a/routers/web/repo/editor.go b/routers/web/repo/editor.go index 8c630cb35f..983249a6d2 100644 --- a/routers/web/repo/editor.go +++ b/routers/web/repo/editor.go @@ -41,7 +41,12 @@ const ( editorCommitChoiceNewBranch string = "commit-to-new-branch" ) -func prepareEditorCommitFormOptions(ctx *context.Context, editorAction string) *context.CommitFormOptions { +func prepareEditorPage(ctx *context.Context, editorAction string) *context.CommitFormOptions { + prepareHomeTreeSideBarSwitch(ctx) + return prepareEditorPageFormOptions(ctx, editorAction) +} + +func prepareEditorPageFormOptions(ctx *context.Context, editorAction string) *context.CommitFormOptions { cleanedTreePath := files_service.CleanGitTreePath(ctx.Repo.TreePath) if cleanedTreePath != ctx.Repo.TreePath { redirectTo := fmt.Sprintf("%s/%s/%s/%s", ctx.Repo.RepoLink, editorAction, util.PathEscapeSegments(ctx.Repo.BranchName), util.PathEscapeSegments(cleanedTreePath)) @@ -283,7 +288,7 @@ func EditFile(ctx *context.Context) { // on the "New File" page, we should add an empty path field to make end users could input a new name prepareTreePathFieldsAndPaths(ctx, util.Iif(isNewFile, ctx.Repo.TreePath+"/", ctx.Repo.TreePath)) - prepareEditorCommitFormOptions(ctx, editorAction) + prepareEditorPage(ctx, editorAction) if ctx.Written() { return } @@ -376,15 +381,16 @@ func EditFilePost(ctx *context.Context) { // DeleteFile render delete file page func DeleteFile(ctx *context.Context) { - prepareEditorCommitFormOptions(ctx, "_delete") + prepareEditorPage(ctx, "_delete") if ctx.Written() { return } ctx.Data["PageIsDelete"] = true + prepareTreePathFieldsAndPaths(ctx, ctx.Repo.TreePath) ctx.HTML(http.StatusOK, tplDeleteFile) } -// DeleteFilePost response for deleting file +// DeleteFilePost response for deleting file or directory func DeleteFilePost(ctx *context.Context) { parsed := prepareEditorCommitSubmittedForm[*forms.DeleteRepoFileForm](ctx) if ctx.Written() { @@ -392,17 +398,37 @@ func DeleteFilePost(ctx *context.Context) { } treePath := ctx.Repo.TreePath - _, err := files_service.ChangeRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, &files_service.ChangeRepoFilesOptions{ + if treePath == "" { + ctx.JSONError("cannot delete root directory") // it should not happen unless someone is trying to be malicious + return + } + + // Check if the path is a directory + entry, err := ctx.Repo.Commit.GetTreeEntryByPath(treePath) + if err != nil { + ctx.NotFoundOrServerError("GetTreeEntryByPath", git.IsErrNotExist, err) + return + } + + var commitMessage string + if entry.IsDir() { + commitMessage = parsed.GetCommitMessage(ctx.Locale.TrString("repo.editor.delete_directory", treePath)) + } else { + commitMessage = parsed.GetCommitMessage(ctx.Locale.TrString("repo.editor.delete", treePath)) + } + + _, err = files_service.ChangeRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, &files_service.ChangeRepoFilesOptions{ LastCommitID: parsed.form.LastCommit, OldBranch: parsed.OldBranchName, NewBranch: parsed.NewBranchName, Files: []*files_service.ChangeRepoFile{ { - Operation: "delete", - TreePath: treePath, + Operation: "delete", + TreePath: treePath, + DeleteRecursively: true, }, }, - Message: parsed.GetCommitMessage(ctx.Locale.TrString("repo.editor.delete", treePath)), + Message: commitMessage, Signoff: parsed.form.Signoff, Author: parsed.GitCommitter, Committer: parsed.GitCommitter, @@ -412,7 +438,11 @@ func DeleteFilePost(ctx *context.Context) { return } - ctx.Flash.Success(ctx.Tr("repo.editor.file_delete_success", treePath)) + if entry.IsDir() { + ctx.Flash.Success(ctx.Tr("repo.editor.directory_delete_success", treePath)) + } else { + ctx.Flash.Success(ctx.Tr("repo.editor.file_delete_success", treePath)) + } redirectTreePath := getClosestParentWithFiles(ctx.Repo.GitRepo, parsed.NewBranchName, treePath) redirectForCommitChoice(ctx, parsed, redirectTreePath) } @@ -420,7 +450,7 @@ func DeleteFilePost(ctx *context.Context) { func UploadFile(ctx *context.Context) { ctx.Data["PageIsUpload"] = true prepareTreePathFieldsAndPaths(ctx, ctx.Repo.TreePath) - opts := prepareEditorCommitFormOptions(ctx, "_upload") + opts := prepareEditorPage(ctx, "_upload") if ctx.Written() { return } diff --git a/routers/web/repo/editor_apply_patch.go b/routers/web/repo/editor_apply_patch.go index aad7b4129c..357c6f3a21 100644 --- a/routers/web/repo/editor_apply_patch.go +++ b/routers/web/repo/editor_apply_patch.go @@ -14,7 +14,7 @@ import ( ) func NewDiffPatch(ctx *context.Context) { - prepareEditorCommitFormOptions(ctx, "_diffpatch") + prepareEditorPage(ctx, "_diffpatch") if ctx.Written() { return } diff --git a/routers/web/repo/editor_cherry_pick.go b/routers/web/repo/editor_cherry_pick.go index 099814a9fa..32e3c58e87 100644 --- a/routers/web/repo/editor_cherry_pick.go +++ b/routers/web/repo/editor_cherry_pick.go @@ -16,7 +16,7 @@ import ( ) func CherryPick(ctx *context.Context) { - prepareEditorCommitFormOptions(ctx, "_cherrypick") + prepareEditorPage(ctx, "_cherrypick") if ctx.Written() { return } diff --git a/routers/web/repo/find.go b/routers/web/repo/find.go deleted file mode 100644 index 3a3a7610e7..0000000000 --- a/routers/web/repo/find.go +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright 2022 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package repo - -import ( - "net/http" - - "code.gitea.io/gitea/modules/templates" - "code.gitea.io/gitea/modules/util" - "code.gitea.io/gitea/services/context" -) - -const ( - tplFindFiles templates.TplName = "repo/find/files" -) - -// FindFiles render the page to find repository files -func FindFiles(ctx *context.Context) { - path := ctx.PathParam("*") - ctx.Data["TreeLink"] = ctx.Repo.RepoLink + "/src/" + util.PathEscapeSegments(path) - ctx.Data["DataLink"] = ctx.Repo.RepoLink + "/tree-list/" + util.PathEscapeSegments(path) - ctx.HTML(http.StatusOK, tplFindFiles) -} diff --git a/routers/web/repo/treelist_test.go b/routers/web/repo/treelist_test.go index 94ba60661b..019fe085d4 100644 --- a/routers/web/repo/treelist_test.go +++ b/routers/web/repo/treelist_test.go @@ -33,7 +33,7 @@ func TestTransformDiffTreeForWeb(t *testing.T) { }) mockIconForFile := func(id string) template.HTML { - return template.HTML(``) + return template.HTML(``) } assert.Equal(t, WebDiffFileTree{ TreeRoot: WebDiffFileItem{ diff --git a/routers/web/repo/view.go b/routers/web/repo/view.go index 09ac33cff4..8e85cc3278 100644 --- a/routers/web/repo/view.go +++ b/routers/web/repo/view.go @@ -245,27 +245,17 @@ func LastCommit(ctx *context.Context) { return } + // The "/lastcommit/" endpoint is used to render the embedded HTML content for the directory file listing with latest commit info + // It needs to construct correct links to the file items, but the route only accepts a commit ID, not a full ref name (branch or tag). + // So we need to get the ref name from the query parameter "refSubUrl". + // TODO: LAST-COMMIT-ASYNC-LOADING: it needs more tests to cover this + refSubURL := path.Clean(ctx.FormString("refSubUrl")) + prepareRepoViewContent(ctx, util.IfZero(refSubURL, ctx.Repo.RefTypeNameSubURL())) renderDirectoryFiles(ctx, 0) if ctx.Written() { return } - var treeNames []string - paths := make([]string, 0, 5) - if len(ctx.Repo.TreePath) > 0 { - treeNames = strings.Split(ctx.Repo.TreePath, "/") - for i := range treeNames { - paths = append(paths, strings.Join(treeNames[:i+1], "/")) - } - - ctx.Data["HasParentPath"] = true - if len(paths)-2 >= 0 { - ctx.Data["ParentPath"] = "/" + paths[len(paths)-2] - } - } - branchLink := ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL() - ctx.Data["BranchLink"] = branchLink - ctx.HTML(http.StatusOK, tplRepoViewList) } @@ -289,7 +279,9 @@ func renderDirectoryFiles(ctx *context.Context, timeout time.Duration) git.Entri return nil } - ctx.Data["LastCommitLoaderURL"] = ctx.Repo.RepoLink + "/lastcommit/" + url.PathEscape(ctx.Repo.CommitID) + "/" + util.PathEscapeSegments(ctx.Repo.TreePath) + // TODO: LAST-COMMIT-ASYNC-LOADING: search this keyword to see more details + lastCommitLoaderURL := ctx.Repo.RepoLink + "/lastcommit/" + url.PathEscape(ctx.Repo.CommitID) + "/" + util.PathEscapeSegments(ctx.Repo.TreePath) + ctx.Data["LastCommitLoaderURL"] = lastCommitLoaderURL + "?refSubUrl=" + url.QueryEscape(ctx.Repo.RefTypeNameSubURL()) // Get current entry user currently looking at. entry, err := ctx.Repo.Commit.GetTreeEntryByPath(ctx.Repo.TreePath) @@ -322,6 +314,21 @@ func renderDirectoryFiles(ctx *context.Context, timeout time.Duration) git.Entri ctx.ServerError("GetCommitsInfo", err) return nil } + + { + if timeout != 0 && !setting.IsProd && !setting.IsInTesting { + log.Debug("first call to get directory file commit info") + clearFilesCommitInfo := func() { + log.Warn("clear directory file commit info to force async loading on frontend") + for i := range files { + files[i].Commit = nil + } + } + _ = clearFilesCommitInfo + // clearFilesCommitInfo() // TODO: LAST-COMMIT-ASYNC-LOADING: debug the frontend async latest commit info loading, uncomment this line, and it needs more tests + } + } + ctx.Data["Files"] = files prepareDirectoryFileIcons(ctx, files) for _, f := range files { @@ -334,16 +341,6 @@ func renderDirectoryFiles(ctx *context.Context, timeout time.Duration) git.Entri if !loadLatestCommitData(ctx, latestCommit) { return nil } - - branchLink := ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL() - treeLink := branchLink - - if len(ctx.Repo.TreePath) > 0 { - treeLink += "/" + util.PathEscapeSegments(ctx.Repo.TreePath) - } - - ctx.Data["TreeLink"] = treeLink - return allEntries } diff --git a/routers/web/repo/view_home.go b/routers/web/repo/view_home.go index 17043055e5..00d30bedef 100644 --- a/routers/web/repo/view_home.go +++ b/routers/web/repo/view_home.go @@ -362,6 +362,32 @@ func redirectFollowSymlink(ctx *context.Context, treePathEntry *git.TreeEntry) b return false } +func prepareRepoViewContent(ctx *context.Context, refTypeNameSubURL string) { + // for: home, file list, file view, blame + ctx.Data["PageIsViewCode"] = true + ctx.Data["RepositoryUploadEnabled"] = setting.Repository.Upload.Enabled // show Upload File button or menu item + + // prepare the tree path navigation + var treeNames, paths []string + branchLink := ctx.Repo.RepoLink + "/src/" + refTypeNameSubURL + treeLink := branchLink + if ctx.Repo.TreePath != "" { + treeLink += "/" + util.PathEscapeSegments(ctx.Repo.TreePath) + treeNames = strings.Split(ctx.Repo.TreePath, "/") + for i := range treeNames { + paths = append(paths, strings.Join(treeNames[:i+1], "/")) + } + ctx.Data["HasParentPath"] = true + if len(paths)-2 >= 0 { + ctx.Data["ParentPath"] = "/" + paths[len(paths)-2] + } + } + ctx.Data["Paths"] = paths + ctx.Data["TreeLink"] = treeLink + ctx.Data["TreeNames"] = treeNames + ctx.Data["BranchLink"] = branchLink +} + // Home render repository home page func Home(ctx *context.Context) { if handleRepoHomeFeed(ctx) { @@ -383,8 +409,7 @@ func Home(ctx *context.Context) { title += ": " + ctx.Repo.Repository.Description } ctx.Data["Title"] = title - ctx.Data["PageIsViewCode"] = true - ctx.Data["RepositoryUploadEnabled"] = setting.Repository.Upload.Enabled // show New File / Upload File buttons + prepareRepoViewContent(ctx, ctx.Repo.RefTypeNameSubURL()) if ctx.Repo.Commit == nil || ctx.Repo.Repository.IsEmpty || ctx.Repo.Repository.IsBroken() { // empty or broken repositories need to be handled differently @@ -405,26 +430,6 @@ func Home(ctx *context.Context) { return } - // prepare the tree path - var treeNames, paths []string - branchLink := ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL() - treeLink := branchLink - if ctx.Repo.TreePath != "" { - treeLink += "/" + util.PathEscapeSegments(ctx.Repo.TreePath) - treeNames = strings.Split(ctx.Repo.TreePath, "/") - for i := range treeNames { - paths = append(paths, strings.Join(treeNames[:i+1], "/")) - } - ctx.Data["HasParentPath"] = true - if len(paths)-2 >= 0 { - ctx.Data["ParentPath"] = "/" + paths[len(paths)-2] - } - } - ctx.Data["Paths"] = paths - ctx.Data["TreeLink"] = treeLink - ctx.Data["TreeNames"] = treeNames - ctx.Data["BranchLink"] = branchLink - // some UI components are only shown when the tree path is root isTreePathRoot := ctx.Repo.TreePath == "" @@ -455,7 +460,7 @@ func Home(ctx *context.Context) { if isViewHomeOnlyContent(ctx) { ctx.HTML(http.StatusOK, tplRepoViewContent) - } else if len(treeNames) != 0 { + } else if ctx.Repo.TreePath != "" { ctx.HTML(http.StatusOK, tplRepoView) } else { ctx.HTML(http.StatusOK, tplRepoHome) diff --git a/routers/web/web.go b/routers/web/web.go index dd1b391c68..89a570dce0 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1184,7 +1184,6 @@ func registerWebRoutes(m *web.Router) { m.Post("/{username}/{reponame}/markup", optSignIn, context.RepoAssignment, reqUnitsWithMarkdown, web.Bind(structs.MarkupOption{}), misc.Markup) m.Group("/{username}/{reponame}", func() { - m.Get("/find/*", repo.FindFiles) m.Group("/tree-list", func() { m.Get("/branch/*", context.RepoRefByType(git.RefTypeBranch), repo.TreeList) m.Get("/tag/*", context.RepoRefByType(git.RefTypeTag), repo.TreeList) diff --git a/services/auth/source/pam/source_authenticate.go b/services/auth/source/pam/source_authenticate.go index db7c6aab96..fc290aa771 100644 --- a/services/auth/source/pam/source_authenticate.go +++ b/services/auth/source/pam/source_authenticate.go @@ -35,9 +35,9 @@ func (source *Source) Authenticate(ctx context.Context, user *user_model.User, u // Allow PAM sources with `@` in their name, like from Active Directory username := pamLogin email := pamLogin - idx := strings.Index(pamLogin, "@") - if idx > -1 { - username = pamLogin[:idx] + before, _, ok := strings.Cut(pamLogin, "@") + if ok { + username = before } if user_model.ValidateEmail(email) != nil { if source.EmailDomain != "" { diff --git a/services/auth/source/smtp/source_authenticate.go b/services/auth/source/smtp/source_authenticate.go index b8e668f5f9..de39c1d3a6 100644 --- a/services/auth/source/smtp/source_authenticate.go +++ b/services/auth/source/smtp/source_authenticate.go @@ -21,10 +21,10 @@ import ( func (source *Source) Authenticate(ctx context.Context, user *user_model.User, userName, password string) (*user_model.User, error) { // Verify allowed domains. if len(source.AllowedDomains) > 0 { - idx := strings.Index(userName, "@") - if idx == -1 { + _, after, ok := strings.Cut(userName, "@") + if !ok { return nil, user_model.ErrUserNotExist{Name: userName} - } else if !util.SliceContainsString(strings.Split(source.AllowedDomains, ","), userName[idx+1:], true) { + } else if !util.SliceContainsString(strings.Split(source.AllowedDomains, ","), after, true) { return nil, user_model.ErrUserNotExist{Name: userName} } } @@ -61,9 +61,9 @@ func (source *Source) Authenticate(ctx context.Context, user *user_model.User, u } username := userName - idx := strings.Index(userName, "@") - if idx > -1 { - username = userName[:idx] + before, _, ok := strings.Cut(userName, "@") + if ok { + username = before } user = &user_model.User{ diff --git a/services/mailer/sender/sendmail.go b/services/mailer/sender/sendmail.go index 64c7f8f081..7064c60f97 100644 --- a/services/mailer/sender/sendmail.go +++ b/services/mailer/sender/sendmail.go @@ -33,7 +33,13 @@ func (s *SendmailSender) Send(from string, to []string, msg io.WriterTo) error { args := []string{"-f", envelopeFrom, "-i"} args = append(args, setting.MailService.SendmailArgs...) - args = append(args, to...) + for _, recipient := range to { + smtpTo, err := sanitizeEmailAddress(recipient) + if err != nil { + return fmt.Errorf("invalid recipient address %q: %w", recipient, err) + } + args = append(args, smtpTo) + } log.Trace("Sending with: %s %v", setting.MailService.SendmailPath, args) desc := fmt.Sprintf("SendMail: %s %v", setting.MailService.SendmailPath, args) diff --git a/services/mailer/sender/smtp.go b/services/mailer/sender/smtp.go index 3207eee32f..fa07803359 100644 --- a/services/mailer/sender/smtp.go +++ b/services/mailer/sender/smtp.go @@ -9,13 +9,13 @@ import ( "fmt" "io" "net" + "net/mail" + "net/smtp" "os" "strings" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" - - "github.com/wneessen/go-mail/smtp" ) // SMTPSender Sender SMTP mail sender @@ -108,7 +108,7 @@ func (s *SMTPSender) Send(from string, to []string, msg io.WriterTo) error { if strings.Contains(options, "CRAM-MD5") { auth = smtp.CRAMMD5Auth(opts.User, opts.Passwd) } else if strings.Contains(options, "PLAIN") { - auth = smtp.PlainAuth("", opts.User, opts.Passwd, host, false) + auth = smtp.PlainAuth("", opts.User, opts.Passwd, host) } else if strings.Contains(options, "LOGIN") { // Patch for AUTH LOGIN auth = LoginAuth(opts.User, opts.Passwd) @@ -123,18 +123,24 @@ func (s *SMTPSender) Send(from string, to []string, msg io.WriterTo) error { } } - if opts.OverrideEnvelopeFrom { - if err = client.Mail(opts.EnvelopeFrom); err != nil { - return fmt.Errorf("failed to issue MAIL command: %w", err) - } - } else { - if err = client.Mail(fmt.Sprintf("<%s>", from)); err != nil { - return fmt.Errorf("failed to issue MAIL command: %w", err) - } + fromAddr := from + if opts.OverrideEnvelopeFrom && opts.EnvelopeFrom != "" { + fromAddr = opts.EnvelopeFrom + } + smtpFrom, err := sanitizeEmailAddress(fromAddr) + if err != nil { + return fmt.Errorf("invalid envelope from address: %w", err) + } + if err = client.Mail(smtpFrom); err != nil { + return fmt.Errorf("failed to issue MAIL command: %w", err) } for _, rec := range to { - if err = client.Rcpt(rec); err != nil { + smtpTo, err := sanitizeEmailAddress(rec) + if err != nil { + return fmt.Errorf("invalid recipient address %q: %w", rec, err) + } + if err = client.Rcpt(smtpTo); err != nil { return fmt.Errorf("failed to issue RCPT command: %w", err) } } @@ -155,3 +161,11 @@ func (s *SMTPSender) Send(from string, to []string, msg io.WriterTo) error { return nil } + +func sanitizeEmailAddress(raw string) (string, error) { + addr, err := mail.ParseAddress(strings.TrimSpace(strings.Trim(raw, "<>"))) + if err != nil { + return "", err + } + return addr.Address, nil +} diff --git a/services/mailer/sender/smtp_auth.go b/services/mailer/sender/smtp_auth.go index c60e0dbfbb..66ea24e896 100644 --- a/services/mailer/sender/smtp_auth.go +++ b/services/mailer/sender/smtp_auth.go @@ -6,9 +6,9 @@ package sender import ( "errors" "fmt" + "net/smtp" "github.com/Azure/go-ntlmssp" - "github.com/wneessen/go-mail/smtp" ) type loginAuth struct { diff --git a/services/mailer/sender/smtp_test.go b/services/mailer/sender/smtp_test.go new file mode 100644 index 0000000000..1e944583a6 --- /dev/null +++ b/services/mailer/sender/smtp_test.go @@ -0,0 +1,30 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package sender + +import "testing" + +func TestSanitizeEmailAddress(t *testing.T) { + tests := []struct { + input string + expected string + hasError bool + }{ + {"abc@gitea.com", "abc@gitea.com", false}, + {"", "abc@gitea.com", false}, + {"ssss.com", "", true}, + {"", "", true}, + } + + for _, tt := range tests { + result, err := sanitizeEmailAddress(tt.input) + if (err != nil) != tt.hasError { + t.Errorf("sanitizeEmailAddress(%q) unexpected error status: got %v, want error: %v", tt.input, err != nil, tt.hasError) + continue + } + if result != tt.expected { + t.Errorf("sanitizeEmailAddress(%q) = %q; want %q", tt.input, result, tt.expected) + } + } +} diff --git a/services/migrations/onedev_test.go b/services/migrations/onedev_test.go index a05d6cac6e..3319e19851 100644 --- a/services/migrations/onedev_test.go +++ b/services/migrations/onedev_test.go @@ -23,9 +23,6 @@ func TestOneDevDownloadRepo(t *testing.T) { u, _ := url.Parse("https://code.onedev.io") ctx := t.Context() downloader := NewOneDevDownloader(ctx, u, "", "", "go-gitea-test_repo") - if err != nil { - t.Fatalf("NewOneDevDownloader is nil: %v", err) - } repo, err := downloader.GetRepoInfo(ctx) assert.NoError(t, err) assertRepositoryEqual(t, &base.Repository{ diff --git a/services/pull/pull.go b/services/pull/pull.go index 6f0318ea49..04f48f0565 100644 --- a/services/pull/pull.go +++ b/services/pull/pull.go @@ -427,10 +427,16 @@ func AddTestPullRequestTask(opts TestPullRequestOptions) { for _, pr := range headBranchPRs { objectFormat := git.ObjectFormatFromName(pr.BaseRepo.ObjectFormatName) if opts.NewCommitID != "" && opts.NewCommitID != objectFormat.EmptyObjectID().String() { - changed, err := checkIfPRContentChanged(ctx, pr, opts.OldCommitID, opts.NewCommitID) + changed, newMergeBase, err := checkIfPRContentChanged(ctx, pr, opts.OldCommitID, opts.NewCommitID) if err != nil { log.Error("checkIfPRContentChanged: %v", err) } + if newMergeBase != "" && pr.MergeBase != newMergeBase { + pr.MergeBase = newMergeBase + if _, err := pr.UpdateColsIfNotMerged(ctx, "merge_base"); err != nil { + log.Error("Update merge base for %-v: %v", pr, err) + } + } if changed { // Mark old reviews as stale if diff to mergebase has changed if err := issues_model.MarkReviewsAsStale(ctx, pr.IssueID); err != nil { @@ -496,30 +502,30 @@ func AddTestPullRequestTask(opts TestPullRequestOptions) { // checkIfPRContentChanged checks if diff to target branch has changed by push // A commit can be considered to leave the PR untouched if the patch/diff with its merge base is unchanged -func checkIfPRContentChanged(ctx context.Context, pr *issues_model.PullRequest, oldCommitID, newCommitID string) (hasChanged bool, err error) { +func checkIfPRContentChanged(ctx context.Context, pr *issues_model.PullRequest, oldCommitID, newCommitID string) (hasChanged bool, mergeBase string, err error) { prCtx, cancel, err := createTemporaryRepoForPR(ctx, pr) // FIXME: why it still needs to create a temp repo, since the alongside calls like GetDiverging doesn't do so anymore if err != nil { log.Error("CreateTemporaryRepoForPR %-v: %v", pr, err) - return false, err + return false, "", err } defer cancel() tmpRepo, err := git.OpenRepository(ctx, prCtx.tmpBasePath) if err != nil { - return false, fmt.Errorf("OpenRepository: %w", err) + return false, "", fmt.Errorf("OpenRepository: %w", err) } defer tmpRepo.Close() // Find the merge-base - _, base, err := tmpRepo.GetMergeBase("", "base", "tracking") + mergeBase, _, err = tmpRepo.GetMergeBase("", "base", "tracking") if err != nil { - return false, fmt.Errorf("GetMergeBase: %w", err) + return false, "", fmt.Errorf("GetMergeBase: %w", err) } - cmd := gitcmd.NewCommand("diff", "--name-only", "-z").AddDynamicArguments(newCommitID, oldCommitID, base) + cmd := gitcmd.NewCommand("diff", "--name-only", "-z").AddDynamicArguments(newCommitID, oldCommitID, mergeBase) stdoutReader, stdoutWriter, err := os.Pipe() if err != nil { - return false, fmt.Errorf("unable to open pipe for to run diff: %w", err) + return false, mergeBase, fmt.Errorf("unable to open pipe for to run diff: %w", err) } stderr := new(bytes.Buffer) @@ -535,19 +541,19 @@ func checkIfPRContentChanged(ctx context.Context, pr *issues_model.PullRequest, }). Run(ctx); err != nil { if err == util.ErrNotEmpty { - return true, nil + return true, mergeBase, nil } err = gitcmd.ConcatenateError(err, stderr.String()) log.Error("Unable to run diff on %s %s %s in tempRepo for PR[%d]%s/%s...%s/%s: Error: %v", - newCommitID, oldCommitID, base, + newCommitID, oldCommitID, mergeBase, pr.ID, pr.BaseRepo.FullName(), pr.BaseBranch, pr.HeadRepo.FullName(), pr.HeadBranch, err) - return false, fmt.Errorf("Unable to run git diff --name-only -z %s %s %s: %w", newCommitID, oldCommitID, base, err) + return false, mergeBase, fmt.Errorf("Unable to run git diff --name-only -z %s %s %s: %w", newCommitID, oldCommitID, mergeBase, err) } - return false, nil + return false, mergeBase, nil } // PushToBaseRepo pushes commits from branches of head repository to diff --git a/services/pull/review.go b/services/pull/review.go index 3977e351ca..9aeeb4c31d 100644 --- a/services/pull/review.go +++ b/services/pull/review.go @@ -333,7 +333,7 @@ func SubmitReview(ctx context.Context, doer *user_model.User, gitRepo *git.Repos if headCommitID == commitID { stale = false } else { - stale, err = checkIfPRContentChanged(ctx, pr, commitID, headCommitID) + stale, _, err = checkIfPRContentChanged(ctx, pr, commitID, headCommitID) if err != nil { return nil, nil, err } diff --git a/services/repository/files/temp_repo.go b/services/repository/files/temp_repo.go index feb4811bb0..731f23855d 100644 --- a/services/repository/files/temp_repo.go +++ b/services/repository/files/temp_repo.go @@ -135,6 +135,14 @@ func (t *TemporaryUploadRepository) LsFiles(ctx context.Context, filenames ...st return fileList, nil } +func (t *TemporaryUploadRepository) RemoveRecursivelyFromIndex(ctx context.Context, path string) error { + _, _, err := gitcmd.NewCommand("rm", "--cached", "-r"). + AddDynamicArguments(path). + WithDir(t.basePath). + RunStdBytes(ctx) + return err +} + // RemoveFilesFromIndex removes the given files from the index func (t *TemporaryUploadRepository) RemoveFilesFromIndex(ctx context.Context, filenames ...string) error { objFmt, err := t.gitRepo.GetObjectFormat() diff --git a/services/repository/files/tree_test.go b/services/repository/files/tree_test.go index 38ac9f25fc..e7511b3eed 100644 --- a/services/repository/files/tree_test.go +++ b/services/repository/files/tree_test.go @@ -67,13 +67,13 @@ func TestGetTreeViewNodes(t *testing.T) { curRepoLink := "/any/repo-link" renderedIconPool := fileicon.NewRenderedIconPool() mockIconForFile := func(id string) template.HTML { - return template.HTML(``) + return template.HTML(``) } mockIconForFolder := func(id string) template.HTML { - return template.HTML(``) + return template.HTML(``) } mockOpenIconForFolder := func(id string) template.HTML { - return template.HTML(``) + return template.HTML(``) } treeNodes, err := GetTreeViewNodes(ctx, curRepoLink, renderedIconPool, ctx.Repo.Commit, "", "") assert.NoError(t, err) diff --git a/services/repository/files/update.go b/services/repository/files/update.go index b07055d57a..4830f711fc 100644 --- a/services/repository/files/update.go +++ b/services/repository/files/update.go @@ -46,7 +46,10 @@ type ChangeRepoFile struct { FromTreePath string ContentReader io.ReadSeeker SHA string - Options *RepoFileOptions + + DeleteRecursively bool // when deleting, work as `git rm -r ...` + + Options *RepoFileOptions // FIXME: need to refactor, internal usage only } // ChangeRepoFilesOptions holds the repository files update options @@ -69,26 +72,6 @@ type RepoFileOptions struct { executable bool } -// ErrRepoFileDoesNotExist represents a "RepoFileDoesNotExist" kind of error. -type ErrRepoFileDoesNotExist struct { - Path string - Name string -} - -// IsErrRepoFileDoesNotExist checks if an error is a ErrRepoDoesNotExist. -func IsErrRepoFileDoesNotExist(err error) bool { - _, ok := err.(ErrRepoFileDoesNotExist) - return ok -} - -func (err ErrRepoFileDoesNotExist) Error() string { - return fmt.Sprintf("repository file does not exist [path: %s]", err.Path) -} - -func (err ErrRepoFileDoesNotExist) Unwrap() error { - return util.ErrNotExist -} - type LazyReadSeeker interface { io.ReadSeeker io.Closer @@ -217,24 +200,6 @@ func ChangeRepoFiles(ctx context.Context, repo *repo_model.Repository, doer *use } } - for _, file := range opts.Files { - if file.Operation == "delete" { - // Get the files in the index - filesInIndex, err := t.LsFiles(ctx, file.TreePath) - if err != nil { - return nil, fmt.Errorf("DeleteRepoFile: %w", err) - } - - // Find the file we want to delete in the index - inFilelist := slices.Contains(filesInIndex, file.TreePath) - if !inFilelist { - return nil, ErrRepoFileDoesNotExist{ - Path: file.TreePath, - } - } - } - } - if hasOldBranch { // Get the commit of the original branch commit, err := t.GetBranchCommit(opts.OldBranch) @@ -272,8 +237,14 @@ func ChangeRepoFiles(ctx context.Context, repo *repo_model.Repository, doer *use addedLfsPointers = append(addedLfsPointers, *addedLfsPointer) } case "delete": - if err = t.RemoveFilesFromIndex(ctx, file.TreePath); err != nil { - return nil, err + if file.DeleteRecursively { + if err = t.RemoveRecursivelyFromIndex(ctx, file.TreePath); err != nil { + return nil, err + } + } else { + if err = t.RemoveFilesFromIndex(ctx, file.TreePath); err != nil { + return nil, err + } } default: return nil, fmt.Errorf("invalid file operation: %s %s, supported operations are create, update, delete", file.Operation, file.Options.treePath) diff --git a/services/repository/gitgraph/graph_test.go b/services/repository/gitgraph/graph_test.go index eda499840b..83813e7ba7 100644 --- a/services/repository/gitgraph/graph_test.go +++ b/services/repository/gitgraph/graph_test.go @@ -238,8 +238,8 @@ func TestCommitStringParsing(t *testing.T) { for _, test := range tests { t.Run(test.testName, func(t *testing.T) { testString := fmt.Sprintf("%s%s", dataFirstPart, test.commitMessage) - idx := strings.Index(testString, "DATA:") - commit, err := NewCommit(0, 0, []byte(testString[idx+5:])) + _, after, _ := strings.Cut(testString, "DATA:") + commit, err := NewCommit(0, 0, []byte(after)) if err != nil && test.shouldPass { t.Errorf("Could not parse %s", testString) return diff --git a/services/repository/gitgraph/parser.go b/services/repository/gitgraph/parser.go index f6bf9b0b90..859deff113 100644 --- a/services/repository/gitgraph/parser.go +++ b/services/repository/gitgraph/parser.go @@ -44,11 +44,11 @@ func (parser *Parser) Reset() { // AddLineToGraph adds the line as a row to the graph func (parser *Parser) AddLineToGraph(graph *Graph, row int, line []byte) error { - idx := bytes.Index(line, []byte("DATA:")) - if idx < 0 { + before, after, ok := bytes.Cut(line, []byte("DATA:")) + if !ok { parser.ParseGlyphs(line) } else { - parser.ParseGlyphs(line[:idx]) + parser.ParseGlyphs(before) } var err error @@ -72,7 +72,7 @@ func (parser *Parser) AddLineToGraph(graph *Graph, row int, line []byte) error { } } commitDone = true - if idx < 0 { + if !ok { if err != nil { err = fmt.Errorf("missing data section on line %d with commit: %s. %w", row, string(line), err) } else { @@ -80,7 +80,7 @@ func (parser *Parser) AddLineToGraph(graph *Graph, row int, line []byte) error { } continue } - err2 := graph.AddCommit(row, column, flowID, line[idx+5:]) + err2 := graph.AddCommit(row, column, flowID, after) if err != nil && err2 != nil { err = fmt.Errorf("%v %w", err2, err) continue diff --git a/services/user/email.go b/services/user/email.go index 5c0de708e9..c45b3b3ec9 100644 --- a/services/user/email.go +++ b/services/user/email.go @@ -14,60 +14,6 @@ import ( "code.gitea.io/gitea/modules/util" ) -// AdminAddOrSetPrimaryEmailAddress is used by admins to add or set a user's primary email address -func AdminAddOrSetPrimaryEmailAddress(ctx context.Context, u *user_model.User, emailStr string) error { - if strings.EqualFold(u.Email, emailStr) { - return nil - } - - if err := user_model.ValidateEmailForAdmin(emailStr); err != nil { - return err - } - - // Check if address exists already - email, err := user_model.GetEmailAddressByEmail(ctx, emailStr) - if err != nil && !errors.Is(err, util.ErrNotExist) { - return err - } - if email != nil && email.UID != u.ID { - return user_model.ErrEmailAlreadyUsed{Email: emailStr} - } - - // Update old primary address - primary, err := user_model.GetPrimaryEmailAddressOfUser(ctx, u.ID) - if err != nil { - return err - } - - primary.IsPrimary = false - if err := user_model.UpdateEmailAddress(ctx, primary); err != nil { - return err - } - - // Insert new or update existing address - if email != nil { - email.IsPrimary = true - email.IsActivated = true - if err := user_model.UpdateEmailAddress(ctx, email); err != nil { - return err - } - } else { - email = &user_model.EmailAddress{ - UID: u.ID, - Email: emailStr, - IsActivated: true, - IsPrimary: true, - } - if _, err := user_model.InsertEmailAddress(ctx, email); err != nil { - return err - } - } - - u.Email = emailStr - - return user_model.UpdateUserCols(ctx, u, "email") -} - func ReplacePrimaryEmailAddress(ctx context.Context, u *user_model.User, emailStr string) error { if strings.EqualFold(u.Email, emailStr) { return nil @@ -77,43 +23,44 @@ func ReplacePrimaryEmailAddress(ctx context.Context, u *user_model.User, emailSt return err } - if !u.IsOrganization() { - // Check if address exists already - email, err := user_model.GetEmailAddressByEmail(ctx, emailStr) - if err != nil && !errors.Is(err, util.ErrNotExist) { - return err - } - if email != nil { - if email.IsPrimary && email.UID == u.ID { - return nil + return db.WithTx(ctx, func(ctx context.Context) error { + if !u.IsOrganization() { + // Check if address exists already + email, err := user_model.GetEmailAddressByEmail(ctx, emailStr) + if err != nil && !errors.Is(err, util.ErrNotExist) { + return err + } + if email != nil { + if email.IsPrimary && email.UID == u.ID { + return nil + } + return user_model.ErrEmailAlreadyUsed{Email: emailStr} + } + + // Remove old primary address + primary, err := user_model.GetPrimaryEmailAddressOfUser(ctx, u.ID) + if err != nil { + return err + } + if _, err := db.DeleteByID[user_model.EmailAddress](ctx, primary.ID); err != nil { + return err + } + + // Insert new primary address + if _, err := user_model.InsertEmailAddress(ctx, &user_model.EmailAddress{ + UID: u.ID, + Email: emailStr, + IsActivated: true, + IsPrimary: true, + }); err != nil { + return err } - return user_model.ErrEmailAlreadyUsed{Email: emailStr} } - // Remove old primary address - primary, err := user_model.GetPrimaryEmailAddressOfUser(ctx, u.ID) - if err != nil { - return err - } - if _, err := db.DeleteByID[user_model.EmailAddress](ctx, primary.ID); err != nil { - return err - } + u.Email = emailStr - // Insert new primary address - email = &user_model.EmailAddress{ - UID: u.ID, - Email: emailStr, - IsActivated: true, - IsPrimary: true, - } - if _, err := user_model.InsertEmailAddress(ctx, email); err != nil { - return err - } - } - - u.Email = emailStr - - return user_model.UpdateUserCols(ctx, u, "email") + return user_model.UpdateUserCols(ctx, u, "email") + }) } func AddEmailAddresses(ctx context.Context, u *user_model.User, emails []string) error { diff --git a/services/user/email_test.go b/services/user/email_test.go index 76770a9230..a031b12cad 100644 --- a/services/user/email_test.go +++ b/services/user/email_test.go @@ -9,61 +9,10 @@ import ( organization_model "code.gitea.io/gitea/models/organization" "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/glob" - "code.gitea.io/gitea/modules/setting" "github.com/stretchr/testify/assert" ) -func TestAdminAddOrSetPrimaryEmailAddress(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) - - user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 27}) - - emails, err := user_model.GetEmailAddresses(t.Context(), user.ID) - assert.NoError(t, err) - assert.Len(t, emails, 1) - - primary, err := user_model.GetPrimaryEmailAddressOfUser(t.Context(), user.ID) - assert.NoError(t, err) - assert.NotEqual(t, "new-primary@example.com", primary.Email) - assert.Equal(t, user.Email, primary.Email) - - assert.NoError(t, AdminAddOrSetPrimaryEmailAddress(t.Context(), user, "new-primary@example.com")) - - primary, err = user_model.GetPrimaryEmailAddressOfUser(t.Context(), user.ID) - assert.NoError(t, err) - assert.Equal(t, "new-primary@example.com", primary.Email) - assert.Equal(t, user.Email, primary.Email) - - emails, err = user_model.GetEmailAddresses(t.Context(), user.ID) - assert.NoError(t, err) - assert.Len(t, emails, 2) - - setting.Service.EmailDomainAllowList = []glob.Glob{glob.MustCompile("example.org")} - defer func() { - setting.Service.EmailDomainAllowList = []glob.Glob{} - }() - - assert.NoError(t, AdminAddOrSetPrimaryEmailAddress(t.Context(), user, "new-primary2@example2.com")) - - primary, err = user_model.GetPrimaryEmailAddressOfUser(t.Context(), user.ID) - assert.NoError(t, err) - assert.Equal(t, "new-primary2@example2.com", primary.Email) - assert.Equal(t, user.Email, primary.Email) - - assert.NoError(t, AdminAddOrSetPrimaryEmailAddress(t.Context(), user, "user27@example.com")) - - primary, err = user_model.GetPrimaryEmailAddressOfUser(t.Context(), user.ID) - assert.NoError(t, err) - assert.Equal(t, "user27@example.com", primary.Email) - assert.Equal(t, user.Email, primary.Email) - - emails, err = user_model.GetEmailAddresses(t.Context(), user.ID) - assert.NoError(t, err) - assert.Len(t, emails, 3) -} - func TestReplacePrimaryEmailAddress(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) diff --git a/templates/repo/editor/delete.tmpl b/templates/repo/editor/delete.tmpl index bf6143f1cb..70769326a7 100644 --- a/templates/repo/editor/delete.tmpl +++ b/templates/repo/editor/delete.tmpl @@ -1,13 +1,30 @@ {{template "base/head" .}}
{{template "repo/header" .}} -
+
{{template "base/alert" .}} -
- {{.CsrfTokenHtml}} - {{template "repo/editor/common_top" .}} - {{template "repo/editor/commit_form" .}} -
+
+ {{template "repo/view_file_tree" .}} +
+
+ {{.CsrfTokenHtml}} + {{template "repo/editor/common_top" .}} +
+ {{/* although the UI isn't good enough, this header is necessary for the "left file tree view" toggle button, this button must exist */}} + {{template "repo/view_file_tree_toggle_button" .}} + {{/* then, to make the page looks overall good, add the breadcrumb here to make the toggle button can be shown in a text row, but not a single button*/}} + +
+ {{template "repo/editor/commit_form" .}} +
+
+
{{template "base/footer" .}} diff --git a/templates/repo/editor/edit.tmpl b/templates/repo/editor/edit.tmpl index 0911d02e1f..e6b9c55770 100644 --- a/templates/repo/editor/edit.tmpl +++ b/templates/repo/editor/edit.tmpl @@ -1,53 +1,59 @@ {{template "base/head" .}}
{{template "repo/header" .}} -
+
{{template "base/alert" .}} -
+ {{template "repo/view_file_tree" .}} +
+ - {{.CsrfTokenHtml}} - {{template "repo/editor/common_top" .}} -
- {{template "repo/editor/common_breadcrumb" .}} + > + {{.CsrfTokenHtml}} + {{template "repo/editor/common_top" .}} +
+ {{template "repo/view_file_tree_toggle_button" .}} + {{template "repo/editor/common_breadcrumb" .}} +
+ {{if not .NotEditableReason}} + + {{else}} +
+
+

{{.NotEditableReason}}

+

{{ctx.Locale.Tr "repo.editor.file_not_editable_hint"}}

+
+
+ {{end}} + {{template "repo/editor/commit_form" .}} +
- {{if not .NotEditableReason}} - - {{else}} -
-
-

{{.NotEditableReason}}

-

{{ctx.Locale.Tr "repo.editor.file_not_editable_hint"}}

-
-
- {{end}} - {{template "repo/editor/commit_form" .}} - +
{{template "base/footer" .}} diff --git a/templates/repo/editor/upload.tmpl b/templates/repo/editor/upload.tmpl index 3e36c77b3b..847d6df88d 100644 --- a/templates/repo/editor/upload.tmpl +++ b/templates/repo/editor/upload.tmpl @@ -1,19 +1,25 @@ {{template "base/head" .}}
{{template "repo/header" .}} -
+
{{template "base/alert" .}} -
- {{.CsrfTokenHtml}} - {{template "repo/editor/common_top" .}} -
- {{template "repo/editor/common_breadcrumb" .}} +
+ {{template "repo/view_file_tree" .}} +
+ + {{.CsrfTokenHtml}} + {{template "repo/editor/common_top" .}} +
+ {{template "repo/view_file_tree_toggle_button" .}} + {{template "repo/editor/common_breadcrumb" .}} +
+
+ {{template "repo/upload" .}} +
+ {{template "repo/editor/commit_form" .}} +
-
- {{template "repo/upload" .}} -
- {{template "repo/editor/commit_form" .}} - +
{{template "base/footer" .}} diff --git a/templates/repo/find/files.tmpl b/templates/repo/find/files.tmpl deleted file mode 100644 index ce242796be..0000000000 --- a/templates/repo/find/files.tmpl +++ /dev/null @@ -1,21 +0,0 @@ -{{template "base/head" .}} -
- {{template "repo/header" .}} -
-
- {{.RepoName}} - / -
- -
-
- - - -
-
-

{{ctx.Locale.Tr "repo.find_file.no_matching"}}

-
-
-
-{{template "base/footer" .}} diff --git a/templates/repo/view.tmpl b/templates/repo/view.tmpl index f99fe2f57a..99f2a7da7e 100644 --- a/templates/repo/view.tmpl +++ b/templates/repo/view.tmpl @@ -17,9 +17,7 @@ {{template "repo/code/recently_pushed_new_branches" dict "RecentBranchesPromptData" .RecentBranchesPromptData}}
-
- {{template "repo/view_file_tree" .}} -
+ {{template "repo/view_file_tree" .}}
{{template "repo/view_content" .}}
diff --git a/templates/repo/view_content.tmpl b/templates/repo/view_content.tmpl index 66e4fffcb9..b31648fbbe 100644 --- a/templates/repo/view_content.tmpl +++ b/templates/repo/view_content.tmpl @@ -5,11 +5,7 @@
{{if not $isTreePathRoot}} - + {{template "repo/view_file_tree_toggle_button" .}} {{end}} {{template "repo/branch_dropdown" dict @@ -37,31 +33,6 @@ {{end}} - - {{if $isTreePathRoot}} - {{ctx.Locale.Tr "repo.find_file.go_to_file"}} - {{end}} - - {{if and .RefFullName.IsBranch (not .IsViewFile)}} - - {{end}} - {{if and $isTreePathRoot .Repository.IsTemplate}} {{ctx.Locale.Tr "repo.use_template"}} @@ -86,12 +57,65 @@
+
+ + {{if .RefFullName.IsBranch}} + {{$addFilePath := .TreePath}} + {{if .IsViewFile}} + {{if gt (len .TreeNames) 1}} + {{$addFilePath = StringUtils.Join (slice .TreeNames 0 (Eval (len .TreeNames) "-" 1)) "/"}} + {{else}} + {{$addFilePath = ""}} + {{end}} + {{end}} +
+ + {{if and (not .IsViewFile) (not $isTreePathRoot)}} + + {{end}} + {{end}} {{if $isTreePathRoot}} {{template "repo/clone_panel" .}} {{end}} {{if and (not $isTreePathRoot) (not .IsViewFile) (not .IsBlame)}}{{/* IsViewDirectory (not home), TODO: split the templates, avoid using "if" tricks */}} - + {{svg "octicon-history" 16 "tw-mr-2"}}{{ctx.Locale.Tr "repo.file_history"}} {{end}} diff --git a/templates/repo/view_file_tree.tmpl b/templates/repo/view_file_tree.tmpl index 8aed05f346..f79fcc22aa 100644 --- a/templates/repo/view_file_tree.tmpl +++ b/templates/repo/view_file_tree.tmpl @@ -1,15 +1,17 @@ -
- - {{ctx.Locale.Tr "files"}} -
+
+
+ + {{ctx.Locale.Tr "files"}} +
-{{/* TODO: Dynamically move components such as refSelector and createPR here */}} -
+ {{/* TODO: Dynamically move components such as refSelector and createPR here */}} +
+
diff --git a/templates/repo/view_file_tree_toggle_button.tmpl b/templates/repo/view_file_tree_toggle_button.tmpl new file mode 100644 index 0000000000..3d6ea928ed --- /dev/null +++ b/templates/repo/view_file_tree_toggle_button.tmpl @@ -0,0 +1,6 @@ + diff --git a/templates/repo/view_list.tmpl b/templates/repo/view_list.tmpl index 145494aa1a..61443ac465 100644 --- a/templates/repo/view_list.tmpl +++ b/templates/repo/view_list.tmpl @@ -47,7 +47,7 @@ {{end}} {{end}}
-
+
{{if $commit}} {{$commitLink := printf "%s/commit/%s" $.RepoLink (PathEscape $commit.ID.String)}} {{ctx.RenderUtils.RenderCommitMessageLinkSubject $commit.Message $commitLink $.Repository}} diff --git a/tests/integration/actions_trigger_test.go b/tests/integration/actions_trigger_test.go index d5486b6a39..9dc0ddb9df 100644 --- a/tests/integration/actions_trigger_test.go +++ b/tests/integration/actions_trigger_test.go @@ -30,6 +30,7 @@ import ( "code.gitea.io/gitea/modules/test" "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" + webhook_module "code.gitea.io/gitea/modules/webhook" issue_service "code.gitea.io/gitea/services/issue" pull_service "code.gitea.io/gitea/services/pull" release_service "code.gitea.io/gitea/services/release" @@ -1595,3 +1596,56 @@ jobs: assert.NotNil(t, run) }) } + +func TestPullRequestWithPathsRebase(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + session := loginUser(t, user2.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + + repoName := "actions-pr-paths-rebase" + apiRepo := createActionsTestRepo(t, token, repoName, false) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID}) + apiCtx := NewAPITestContext(t, "user2", repoName, auth_model.AccessTokenScopeWriteRepository) + runner := newMockRunner() + runner.registerAsRepoRunner(t, "user2", repoName, "mock-runner", []string{"ubuntu-latest"}, false) + + // init files and dirs + testCreateFile(t, session, "user2", repoName, repo.DefaultBranch, "", "dir1/dir1.txt", "1") + testCreateFile(t, session, "user2", repoName, repo.DefaultBranch, "", "dir2/dir2.txt", "2") + wfFileContent := `name: ci +on: + pull_request: + paths: + - 'dir1/**' +jobs: + ci-job: + runs-on: ubuntu-latest + steps: + - run: echo 'ci' +` + testCreateFile(t, session, "user2", repoName, repo.DefaultBranch, "", ".gitea/workflows/ci.yml", wfFileContent) + + // create a PR to modify "dir1/dir1.txt", the workflow will be triggered + testEditFileToNewBranch(t, session, "user2", repoName, repo.DefaultBranch, "update-dir1", "dir1/dir1.txt", "11") + _, err := doAPICreatePullRequest(apiCtx, "user2", repoName, repo.DefaultBranch, "update-dir1")(t) + assert.NoError(t, err) + pr1Task := runner.fetchTask(t) + _, _, pr1Run := getTaskAndJobAndRunByTaskID(t, pr1Task.Id) + assert.Equal(t, webhook_module.HookEventPullRequest, pr1Run.Event) + + // create a PR to modify "dir2/dir2.txt" then update main branch and rebase, the workflow will not be triggered + testEditFileToNewBranch(t, session, "user2", repoName, repo.DefaultBranch, "update-dir2", "dir2/dir2.txt", "22") + apiPull, err := doAPICreatePullRequest(apiCtx, "user2", repoName, repo.DefaultBranch, "update-dir2")(t) + runner.fetchNoTask(t) + assert.NoError(t, err) + testEditFile(t, session, "user2", repoName, repo.DefaultBranch, "dir1/dir1.txt", "11") // change the file in "dir1" + req := NewRequestWithValues(t, "POST", + fmt.Sprintf("/%s/%s/pulls/%d/update?style=rebase", "user2", repoName, apiPull.Index), // update by rebase + map[string]string{ + "_csrf": GetUserCSRFToken(t, session), + }) + session.MakeRequest(t, req, http.StatusSeeOther) + runner.fetchNoTask(t) + }) +} diff --git a/tests/integration/api_admin_test.go b/tests/integration/api_admin_test.go index dbd62c4078..763d4d526b 100644 --- a/tests/integration/api_admin_test.go +++ b/tests/integration/api_admin_test.go @@ -382,10 +382,12 @@ func TestAPIEditUser_NotAllowedEmailDomain(t *testing.T) { SourceID: 0, Email: &newEmail, }).AddTokenAuth(token) - resp := MakeRequest(t, req, http.StatusOK) - assert.Equal(t, "the domain of user email user2@example1.com conflicts with EMAIL_DOMAIN_ALLOWLIST or EMAIL_DOMAIN_BLOCKLIST", resp.Header().Get("X-Gitea-Warning")) + resp := MakeRequest(t, req, http.StatusBadRequest) + errMap := make(map[string]string) + assert.NoError(t, json.Unmarshal(resp.Body.Bytes(), &errMap)) + assert.Equal(t, "the domain of user email user2@example1.com conflicts with EMAIL_DOMAIN_ALLOWLIST or EMAIL_DOMAIN_BLOCKLIST", errMap["message"]) - originalEmail := "user2@example.com" + originalEmail := "user2@example.org" req = NewRequestWithJSON(t, "PATCH", urlStr, api.EditUserOption{ LoginName: "user2", SourceID: 0, diff --git a/tests/integration/api_repo_file_helpers.go b/tests/integration/api_repo_file_helpers.go index f8d6fc803f..9a4c448664 100644 --- a/tests/integration/api_repo_file_helpers.go +++ b/tests/integration/api_repo_file_helpers.go @@ -5,6 +5,7 @@ package integration import ( "context" + "errors" "strings" "testing" @@ -72,7 +73,7 @@ func deleteFileInBranch(user *user_model.User, repo *repo_model.Repository, tree func createOrReplaceFileInBranch(user *user_model.User, repo *repo_model.Repository, treePath, branchName, content string) error { _, err := deleteFileInBranch(user, repo, treePath, branchName) - if err != nil && !files_service.IsErrRepoFileDoesNotExist(err) { + if err != nil && !errors.Is(err, util.ErrNotExist) { return err } diff --git a/tests/integration/git_ssh_redirect_test.go b/tests/integration/git_ssh_redirect_test.go index 5e35ed2a74..3ae2652412 100644 --- a/tests/integration/git_ssh_redirect_test.go +++ b/tests/integration/git_ssh_redirect_test.go @@ -6,9 +6,13 @@ package integration import ( "fmt" "net/url" + "os" "testing" auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/modules/structs" + + "github.com/stretchr/testify/assert" ) func TestGitSSHRedirect(t *testing.T) { @@ -16,7 +20,8 @@ func TestGitSSHRedirect(t *testing.T) { } func testGitSSHRedirect(t *testing.T, u *url.URL) { - apiTestContext := NewAPITestContext(t, "user2", "repo1", auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + apiTestContext := NewAPITestContext(t, "user2", "repo1", auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser, auth_model.AccessTokenScopeWriteOrganization) + session := loginUser(t, "user2") withKeyFile(t, "my-testing-key", func(keyFile string) { t.Run("CreateUserKey", doAPICreateUserKey(apiTestContext, "test-key", keyFile)) @@ -38,5 +43,39 @@ func testGitSSHRedirect(t *testing.T, u *url.URL) { t.Run("Clone", doGitClone(t.TempDir(), cloneURL)) }) } + + doAPICreateOrganization(apiTestContext, &structs.CreateOrgOption{ + UserName: "olduser2", + FullName: "Old User2", + })(t) + + cloneURL := createSSHUrl("olduser2/repo1.git", u) + t.Run("Clone Should Fail", doGitCloneFail(cloneURL)) + + doAPICreateOrganizationRepository(apiTestContext, "olduser2", &structs.CreateRepoOption{ + Name: "repo1", + AutoInit: true, + })(t) + testEditFile(t, session, "olduser2", "repo1", "master", "README.md", "This is olduser2's repo1\n") + + dstDir := t.TempDir() + t.Run("Clone", doGitClone(dstDir, cloneURL)) + readMEContent, err := os.ReadFile(dstDir + "/README.md") + assert.NoError(t, err) + assert.Equal(t, "This is olduser2's repo1\n", string(readMEContent)) + + apiTestContext2 := NewAPITestContext(t, "user2", "oldrepo1", auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser, auth_model.AccessTokenScopeWriteOrganization) + doAPICreateRepository(apiTestContext2, false)(t) + testEditFile(t, session, "user2", "oldrepo1", "master", "README.md", "This is user2's oldrepo1\n") + + dstDir = t.TempDir() + cloneURL = createSSHUrl("user2/oldrepo1.git", u) + t.Run("Clone", doGitClone(dstDir, cloneURL)) + readMEContent, err = os.ReadFile(dstDir + "/README.md") + assert.NoError(t, err) + assert.Equal(t, "This is user2's oldrepo1\n", string(readMEContent)) + + cloneURL = createSSHUrl("olduser2/oldrepo1.git", u) + t.Run("Clone Should Fail", doGitCloneFail(cloneURL)) }) } diff --git a/tests/integration/repofiles_change_test.go b/tests/integration/repofiles_change_test.go index 6821f8bf61..6fd42401c5 100644 --- a/tests/integration/repofiles_change_test.go +++ b/tests/integration/repofiles_change_test.go @@ -5,6 +5,7 @@ package integration import ( "fmt" + "net/http" "net/url" "path" "strings" @@ -12,7 +13,6 @@ import ( "time" repo_model "code.gitea.io/gitea/models/repo" - "code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/setting" @@ -22,6 +22,7 @@ import ( files_service "code.gitea.io/gitea/services/repository/files" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func getCreateRepoFilesOptions(repo *repo_model.Repository) *files_service.ChangeRepoFilesOptions { @@ -93,55 +94,6 @@ func getUpdateRepoFilesRenameOptions(repo *repo_model.Repository) *files_service } } -func getDeleteRepoFilesOptions(repo *repo_model.Repository) *files_service.ChangeRepoFilesOptions { - return &files_service.ChangeRepoFilesOptions{ - Files: []*files_service.ChangeRepoFile{ - { - Operation: "delete", - TreePath: "README.md", - SHA: "4b4851ad51df6a7d9f25c979345979eaeb5b349f", - }, - }, - LastCommitID: "", - OldBranch: repo.DefaultBranch, - NewBranch: repo.DefaultBranch, - Message: "Deletes README.md", - Author: &files_service.IdentityOptions{ - GitUserName: "Bob Smith", - GitUserEmail: "bob@smith.com", - }, - Committer: nil, - } -} - -func getExpectedFileResponseForRepoFilesDelete() *api.FileResponse { - // Just returns fields that don't change, i.e. fields with commit SHAs and dates can't be determined - return &api.FileResponse{ - Content: nil, - Commit: &api.FileCommitResponse{ - Author: &api.CommitUser{ - Identity: api.Identity{ - Name: "Bob Smith", - Email: "bob@smith.com", - }, - }, - Committer: &api.CommitUser{ - Identity: api.Identity{ - Name: "Bob Smith", - Email: "bob@smith.com", - }, - }, - Message: "Deletes README.md\n", - }, - Verification: &api.PayloadCommitVerification{ - Verified: false, - Reason: "gpg.error.not_signed_commit", - Signature: "", - Payload: "", - }, - } -} - func getExpectedFileResponseForRepoFilesCreate(commitID string, lastCommit *git.Commit) *api.FileResponse { treePath := "new/file.txt" encoding := "base64" @@ -578,75 +530,88 @@ func TestChangeRepoFilesWithoutBranchNames(t *testing.T) { } func TestChangeRepoFilesForDelete(t *testing.T) { - onGiteaRun(t, testDeleteRepoFiles) -} + onGiteaRun(t, func(t *testing.T, u *url.URL) { + ctx, _ := contexttest.MockContext(t, "user2/repo1") + ctx.SetPathParam("id", "1") + contexttest.LoadRepo(t, ctx, 1) + contexttest.LoadRepoCommit(t, ctx) + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadGitRepo(t, ctx) + defer ctx.Repo.GitRepo.Close() + repo := ctx.Repo.Repository + doer := ctx.Doer -func testDeleteRepoFiles(t *testing.T, u *url.URL) { - // setup - unittest.PrepareTestEnv(t) - ctx, _ := contexttest.MockContext(t, "user2/repo1") - ctx.SetPathParam("id", "1") - contexttest.LoadRepo(t, ctx, 1) - contexttest.LoadRepoCommit(t, ctx) - contexttest.LoadUser(t, ctx, 2) - contexttest.LoadGitRepo(t, ctx) - defer ctx.Repo.GitRepo.Close() - repo := ctx.Repo.Repository - doer := ctx.Doer - opts := getDeleteRepoFilesOptions(repo) + t.Run("Delete README.md by commit", func(t *testing.T) { + urlRaw := "/user2/repo1/raw/branch/branch2/README.md" + MakeRequest(t, NewRequest(t, "GET", urlRaw), http.StatusOK) + opts := &files_service.ChangeRepoFilesOptions{ + OldBranch: "branch2", + LastCommitID: "985f0301dba5e7b34be866819cd15ad3d8f508ee", + Files: []*files_service.ChangeRepoFile{ + { + Operation: "delete", + TreePath: "README.md", + }, + }, + Message: "test message", + } + filesResponse, err := files_service.ChangeRepoFiles(t.Context(), repo, doer, opts) + require.NoError(t, err) + assert.NotNil(t, filesResponse) + assert.Nil(t, filesResponse.Files[0]) + assert.Equal(t, "test message\n", filesResponse.Commit.Message) + MakeRequest(t, NewRequest(t, "GET", urlRaw), http.StatusNotFound) + }) - t.Run("Delete README.md file", func(t *testing.T) { - filesResponse, err := files_service.ChangeRepoFiles(t.Context(), repo, doer, opts) - assert.NoError(t, err) - expectedFileResponse := getExpectedFileResponseForRepoFilesDelete() - assert.NotNil(t, filesResponse) - assert.Nil(t, filesResponse.Files[0]) - assert.Equal(t, expectedFileResponse.Commit.Message, filesResponse.Commit.Message) - assert.Equal(t, expectedFileResponse.Commit.Author.Identity, filesResponse.Commit.Author.Identity) - assert.Equal(t, expectedFileResponse.Commit.Committer.Identity, filesResponse.Commit.Committer.Identity) - assert.Equal(t, expectedFileResponse.Verification, filesResponse.Verification) - }) + t.Run("Delete README.md with options", func(t *testing.T) { + urlRaw := "/user2/repo1/raw/branch/master/README.md" + MakeRequest(t, NewRequest(t, "GET", urlRaw), http.StatusOK) + opts := &files_service.ChangeRepoFilesOptions{ + Files: []*files_service.ChangeRepoFile{ + { + Operation: "delete", + TreePath: "README.md", + SHA: "4b4851ad51df6a7d9f25c979345979eaeb5b349f", + }, + }, + OldBranch: repo.DefaultBranch, + NewBranch: repo.DefaultBranch, + Message: "Message for deleting README.md", + Author: &files_service.IdentityOptions{GitUserName: "Bob Smith", GitUserEmail: "bob@smith.com"}, + } + filesResponse, err := files_service.ChangeRepoFiles(t.Context(), repo, doer, opts) + require.NoError(t, err) + require.NotNil(t, filesResponse) + assert.Nil(t, filesResponse.Files[0]) + assert.Equal(t, "Message for deleting README.md\n", filesResponse.Commit.Message) + assert.Equal(t, api.Identity{Name: "Bob Smith", Email: "bob@smith.com"}, filesResponse.Commit.Author.Identity) + assert.Equal(t, api.Identity{Name: "Bob Smith", Email: "bob@smith.com"}, filesResponse.Commit.Committer.Identity) + assert.Equal(t, &api.PayloadCommitVerification{Reason: "gpg.error.not_signed_commit"}, filesResponse.Verification) + MakeRequest(t, NewRequest(t, "GET", urlRaw), http.StatusNotFound) + }) - t.Run("Verify README.md has been deleted", func(t *testing.T) { - filesResponse, err := files_service.ChangeRepoFiles(t.Context(), repo, doer, opts) - assert.Nil(t, filesResponse) - expectedError := "repository file does not exist [path: " + opts.Files[0].TreePath + "]" - assert.EqualError(t, err, expectedError) - }) -} - -// Test opts with branch names removed, same results -func TestChangeRepoFilesForDeleteWithoutBranchNames(t *testing.T) { - onGiteaRun(t, testDeleteRepoFilesWithoutBranchNames) -} - -func testDeleteRepoFilesWithoutBranchNames(t *testing.T, u *url.URL) { - // setup - unittest.PrepareTestEnv(t) - ctx, _ := contexttest.MockContext(t, "user2/repo1") - ctx.SetPathParam("id", "1") - contexttest.LoadRepo(t, ctx, 1) - contexttest.LoadRepoCommit(t, ctx) - contexttest.LoadUser(t, ctx, 2) - contexttest.LoadGitRepo(t, ctx) - defer ctx.Repo.GitRepo.Close() - - repo := ctx.Repo.Repository - doer := ctx.Doer - opts := getDeleteRepoFilesOptions(repo) - opts.OldBranch = "" - opts.NewBranch = "" - - t.Run("Delete README.md without Branch Name", func(t *testing.T) { - filesResponse, err := files_service.ChangeRepoFiles(t.Context(), repo, doer, opts) - assert.NoError(t, err) - expectedFileResponse := getExpectedFileResponseForRepoFilesDelete() - assert.NotNil(t, filesResponse) - assert.Nil(t, filesResponse.Files[0]) - assert.Equal(t, expectedFileResponse.Commit.Message, filesResponse.Commit.Message) - assert.Equal(t, expectedFileResponse.Commit.Author.Identity, filesResponse.Commit.Author.Identity) - assert.Equal(t, expectedFileResponse.Commit.Committer.Identity, filesResponse.Commit.Committer.Identity) - assert.Equal(t, expectedFileResponse.Verification, filesResponse.Verification) + t.Run("Delete directory", func(t *testing.T) { + urlRaw := "/user2/repo1/raw/branch/sub-home-md-img-check/docs/README.md" + MakeRequest(t, NewRequest(t, "GET", urlRaw), http.StatusOK) + opts := &files_service.ChangeRepoFilesOptions{ + OldBranch: "sub-home-md-img-check", + LastCommitID: "4649299398e4d39a5c09eb4f534df6f1e1eb87cc", + Files: []*files_service.ChangeRepoFile{ + { + Operation: "delete", + TreePath: "docs", + DeleteRecursively: true, + }, + }, + Message: "test message", + } + filesResponse, err := files_service.ChangeRepoFiles(t.Context(), repo, doer, opts) + require.NoError(t, err) + assert.NotNil(t, filesResponse) + assert.Nil(t, filesResponse.Files[0]) + assert.Equal(t, "test message\n", filesResponse.Commit.Message) + MakeRequest(t, NewRequest(t, "GET", urlRaw), http.StatusNotFound) + }) }) } diff --git a/tools/lint-go-gopls.sh b/tools/lint-go-gopls.sh deleted file mode 100755 index 2cd26ca6fe..0000000000 --- a/tools/lint-go-gopls.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/bin/bash -set -uo pipefail - -cd "$(dirname -- "${BASH_SOURCE[0]}")" && cd .. - -IGNORE_PATTERNS=( - "is deprecated" # TODO: fix these -) - -# lint all go files with 'gopls check' and look for lines starting with the -# current absolute path, indicating a error was found. This is necessary -# because the tool does not set non-zero exit code when errors are found. -# ref: https://github.com/golang/go/issues/67078 -ERROR_LINES=$("$GO" run "$GOPLS_PACKAGE" check -severity=warning "$@" 2>/dev/null | grep -E "^$PWD" | grep -vFf <(printf '%s\n' "${IGNORE_PATTERNS[@]}")); -NUM_ERRORS=$(echo -n "$ERROR_LINES" | wc -l) - -if [ "$NUM_ERRORS" -eq "0" ]; then - exit 0; -else - echo "$ERROR_LINES" - echo "Found $NUM_ERRORS 'gopls check' errors" - exit 1; -fi diff --git a/tsconfig.json b/tsconfig.json index 1daf4b7233..2466faf592 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -40,7 +40,7 @@ "strictBindCallApply": true, "strictBuiltinIteratorReturn": true, "strictFunctionTypes": true, - "strictNullChecks": false, + "strictNullChecks": true, "stripInternal": true, "verbatimModuleSyntax": true, "types": [ diff --git a/web_src/css/editor/fileeditor.css b/web_src/css/editor/fileeditor.css index 698efffc99..12ae97a109 100644 --- a/web_src/css/editor/fileeditor.css +++ b/web_src/css/editor/fileeditor.css @@ -1,23 +1,3 @@ -.repository.file.editor .tab[data-tab="write"] { - padding: 0 !important; -} - -.repository.file.editor .tab[data-tab="write"] .editor-toolbar { - border: 0 !important; -} - -.repository.file.editor .tab[data-tab="write"] .CodeMirror { - border-left: 0; - border-right: 0; - border-bottom: 0; -} - -.repo-editor-header { - display: flex; - margin: 1rem 0; - padding: 3px 0; -} - .editor-toolbar { border-color: var(--color-secondary); } diff --git a/web_src/css/modules/animations.css b/web_src/css/modules/animations.css index 779339c46b..aedf53569a 100644 --- a/web_src/css/modules/animations.css +++ b/web_src/css/modules/animations.css @@ -28,7 +28,7 @@ aspect-ratio: 1; transform: translate(-50%, -50%); animation: isloadingspin 1000ms infinite linear; - border-width: 4px; + border-width: 3px; border-style: solid; border-color: var(--color-secondary) var(--color-secondary) var(--color-secondary-dark-8) var(--color-secondary-dark-8); border-radius: var(--border-radius-full); diff --git a/web_src/css/modules/svg.css b/web_src/css/modules/svg.css index 738ec22cd3..e32fa0911f 100644 --- a/web_src/css/modules/svg.css +++ b/web_src/css/modules/svg.css @@ -1,17 +1,24 @@ -.svg { +/* some material icons have "fill=none" (e.g.: ".txt -> document"), so the CSS styles shouldn't overwrite it, + and material icons should have no "fill" set explicitly, otherwise some like ".editorconfig" won't render correctly */ +.svg:not(.git-entry-icon) { display: inline-block; vertical-align: text-top; fill: currentcolor; } -.svg.git-entry-icon { - fill: transparent; /* some material icons have dark background fill, so need to reset */ -} - .middle .svg { vertical-align: middle; } +/* some browsers like Chrome have a bug: when a SVG is in a "display: none" container and referenced + somewhere else by ``, it won't be rendered correctly. e.g.: ".kts -> kotlin" */ +.svg-icon-container { + position: absolute; + width: 0; + height: 0; + overflow: hidden; +} + /* prevent SVGs from shrinking, like in space-starved flexboxes. the sizes here are cherry-picked for our use cases, feel free to add more. after https://developer.mozilla.org/en-US/docs/Web/CSS/attr#type-or-unit is diff --git a/web_src/css/repo.css b/web_src/css/repo.css index 9b70e0e6db..0bf37ca083 100644 --- a/web_src/css/repo.css +++ b/web_src/css/repo.css @@ -150,63 +150,68 @@ td .commit-summary { } } -.repository.file.list .non-diff-file-content .header .icon { +.non-diff-file-content .header .icon { font-size: 1em; } -.repository.file.list .non-diff-file-content .header .small.icon { +.non-diff-file-content .header .small.icon { font-size: 0.75em; } -.repository.file.list .non-diff-file-content .header .tiny.icon { +.non-diff-file-content .header .tiny.icon { font-size: 0.5em; } -.repository.file.list .non-diff-file-content .header .file-actions .btn-octicon { +.non-diff-file-content .header .file-actions .btn-octicon { line-height: var(--line-height-default); padding: 8px; vertical-align: middle; color: var(--color-text); } -.repository.file.list .non-diff-file-content .header .file-actions .btn-octicon:hover { +.non-diff-file-content .header .file-actions .btn-octicon:hover { color: var(--color-primary); } -.repository.file.list .non-diff-file-content .header .file-actions .btn-octicon-danger:hover { +.non-diff-file-content .header .file-actions .btn-octicon-danger:hover { color: var(--color-red); } -.repository.file.list .non-diff-file-content .header .file-actions .btn-octicon.disabled { +.non-diff-file-content .header .file-actions .btn-octicon.disabled { color: inherit; opacity: var(--opacity-disabled); cursor: default; } -.repository.file.list .non-diff-file-content .plain-text { +.non-diff-file-content .plain-text { padding: 1em 2em; } -.repository.file.list .non-diff-file-content .plain-text pre { +.non-diff-file-content .plain-text pre { overflow-wrap: anywhere; white-space: pre-wrap; } -.repository.file.list .non-diff-file-content .csv { +.non-diff-file-content .csv { overflow-x: auto; padding: 0 !important; } -.repository.file.list .non-diff-file-content pre { +.non-diff-file-content pre { overflow: auto; } -.repository.file.list .non-diff-file-content .asciicast { +.non-diff-file-content .asciicast { padding: 0 !important; } .repo-editor-header { + display: flex; + margin: 1rem 0; + padding: 3px 0; width: 100%; + gap: 0.5em; + align-items: center; } .repo-editor-header input { @@ -216,17 +221,13 @@ td .commit-summary { margin-right: 5px !important; } -.repository.file.editor .tabular.menu .svg { - margin-right: 5px; -} - .repository.file.editor .commit-form-wrapper { - padding-left: 48px; + padding-left: 58px; } .repository.file.editor .commit-form-wrapper .commit-avatar { float: left; - margin-left: -48px; + margin-left: -58px; } .repository.file.editor .commit-form-wrapper .commit-form { @@ -1409,12 +1410,25 @@ td .commit-summary { flex-grow: 1; } -.repo-button-row .ui.button { +.repo-button-row .ui.button, +.repo-view-container .ui.button.repo-view-file-tree-toggle { flex-shrink: 0; margin: 0; min-height: 30px; } +.repo-view-container .ui.button.repo-view-file-tree-toggle { + padding: 0 6px; +} + +.repo-button-row .repo-file-search-container .ui.input { + height: 30px; +} + +.repo-button-row .ui.dropdown > .menu { + margin-top: 4px; +} + tbody.commit-list { vertical-align: baseline; } @@ -1483,6 +1497,12 @@ tbody.commit-list { line-height: initial; } +.commit-body a.commit code, +.commit-summary a.commit code { + /* these links are generated by the render: ... */ + background: inherit; +} + .git-notes.top { text-align: left; } diff --git a/web_src/css/repo/home.css b/web_src/css/repo/home.css index ee371f1b1c..60bf1f17f9 100644 --- a/web_src/css/repo/home.css +++ b/web_src/css/repo/home.css @@ -54,7 +54,9 @@ gap: var(--page-spacing); } -.repo-view-container .repo-view-file-tree-container { +.repo-view-file-tree-container { + display: flex; + flex-direction: column; flex: 0 0 15%; min-width: 0; max-height: 100vh; @@ -65,6 +67,12 @@ overflow-y: hidden; } +@media (max-width: 767.98px) { + .repo-view-file-tree-container { + display: none; + } +} + .repo-view-content { flex: 1; min-width: 0; diff --git a/web_src/css/themes/theme-gitea-dark.css b/web_src/css/themes/theme-gitea-dark.css index 37ff3b2d98..625ab0c1fd 100644 --- a/web_src/css/themes/theme-gitea-dark.css +++ b/web_src/css/themes/theme-gitea-dark.css @@ -243,6 +243,7 @@ gitea-theme-meta-info { --color-highlight-fg: #87651e; --color-highlight-bg: #352c1c; --color-overlay-backdrop: #080808c0; + --color-danger: var(--color-red); accent-color: var(--color-accent); color-scheme: dark; } diff --git a/web_src/css/themes/theme-gitea-light.css b/web_src/css/themes/theme-gitea-light.css index 2798d76583..acf343fcca 100644 --- a/web_src/css/themes/theme-gitea-light.css +++ b/web_src/css/themes/theme-gitea-light.css @@ -243,6 +243,7 @@ gitea-theme-meta-info { --color-highlight-fg: #eed200; --color-highlight-bg: #fffbdd; --color-overlay-backdrop: #080808c0; + --color-danger: var(--color-red); accent-color: var(--color-accent); color-scheme: light; } diff --git a/web_src/js/bootstrap.ts b/web_src/js/bootstrap.ts index 4d3f39f5bf..a94e1d66b0 100644 --- a/web_src/js/bootstrap.ts +++ b/web_src/js/bootstrap.ts @@ -35,7 +35,7 @@ export function showGlobalErrorMessage(msg: string, msgType: Intent = 'error') { const msgCount = Number(msgDiv.getAttribute(`data-global-error-msg-count`)) + 1; msgDiv.setAttribute(`data-global-error-msg-compact`, msgCompact); msgDiv.setAttribute(`data-global-error-msg-count`, msgCount.toString()); - msgDiv.querySelector('.ui.message').textContent = msg + (msgCount > 1 ? ` (${msgCount})` : ''); + msgDiv.querySelector('.ui.message')!.textContent = msg + (msgCount > 1 ? ` (${msgCount})` : ''); msgContainer.prepend(msgDiv); } diff --git a/web_src/js/components/ActivityHeatmap.vue b/web_src/js/components/ActivityHeatmap.vue index d805817630..7c7e0cd94c 100644 --- a/web_src/js/components/ActivityHeatmap.vue +++ b/web_src/js/components/ActivityHeatmap.vue @@ -5,7 +5,7 @@ import {onMounted, shallowRef} from 'vue'; import type {Value as HeatmapValue, Locale as HeatmapLocale} from '@silverwind/vue3-calendar-heatmap'; defineProps<{ - values?: HeatmapValue[]; + values: HeatmapValue[]; locale: { textTotalContributions: string; heatMapLocale: Partial; @@ -28,7 +28,7 @@ const endDate = shallowRef(new Date()); onMounted(() => { // work around issue with first legend color being rendered twice and legend cut off - const legend = document.querySelector('.vch__external-legend-wrapper'); + const legend = document.querySelector('.vch__external-legend-wrapper')!; legend.setAttribute('viewBox', '12 0 80 10'); legend.style.marginRight = '-12px'; }); diff --git a/web_src/js/components/ContextPopup.vue b/web_src/js/components/ContextPopup.vue index 31db902adc..733144aae1 100644 --- a/web_src/js/components/ContextPopup.vue +++ b/web_src/js/components/ContextPopup.vue @@ -11,15 +11,17 @@ const props = defineProps<{ }>(); const loading = shallowRef(false); -const issue = shallowRef(null); +const issue = shallowRef(null); const renderedLabels = shallowRef(''); const errorMessage = shallowRef(''); const createdAt = computed(() => { + if (!issue?.value) return ''; return new Date(issue.value.created_at).toLocaleDateString(undefined, {year: 'numeric', month: 'short', day: 'numeric'}); }); const body = computed(() => { + if (!issue?.value) return ''; const body = issue.value.body.replace(/\n+/g, ' '); return body.length > 85 ? `${body.substring(0, 85)}…` : body; }); diff --git a/web_src/js/components/DashboardRepoList.vue b/web_src/js/components/DashboardRepoList.vue index e4aa11dea9..82c6b527ef 100644 --- a/web_src/js/components/DashboardRepoList.vue +++ b/web_src/js/components/DashboardRepoList.vue @@ -111,9 +111,9 @@ export default defineComponent({ }, mounted() { - const el = document.querySelector('#dashboard-repo-list'); + const el = document.querySelector('#dashboard-repo-list')!; this.changeReposFilter(this.reposFilter); - fomanticQuery(el.querySelector('.ui.dropdown')).dropdown(); + fomanticQuery(el.querySelector('.ui.dropdown')!).dropdown(); this.textArchivedFilterTitles = { 'archived': this.textShowOnlyArchived, diff --git a/web_src/js/components/DiffCommitSelector.vue b/web_src/js/components/DiffCommitSelector.vue index e9aa3c6744..fcc7af1fa0 100644 --- a/web_src/js/components/DiffCommitSelector.vue +++ b/web_src/js/components/DiffCommitSelector.vue @@ -23,7 +23,7 @@ type CommitListResult = { export default defineComponent({ components: {SvgIcon}, data: () => { - const el = document.querySelector('#diff-commit-select'); + const el = document.querySelector('#diff-commit-select')!; return { menuVisible: false, isLoading: false, @@ -35,7 +35,7 @@ export default defineComponent({ mergeBase: el.getAttribute('data-merge-base'), commits: [] as Array, hoverActivated: false, - lastReviewCommitSha: '', + lastReviewCommitSha: '' as string | null, uniqueIdMenu: generateElemId('diff-commit-selector-menu-'), uniqueIdShowAll: generateElemId('diff-commit-selector-show-all-'), }; @@ -165,7 +165,7 @@ export default defineComponent({ }, /** Called when user clicks on since last review */ changesSinceLastReviewClick() { - window.location.assign(`${this.issueLink}/files/${this.lastReviewCommitSha}..${this.commits.at(-1).id}${this.queryParams}`); + window.location.assign(`${this.issueLink}/files/${this.lastReviewCommitSha}..${this.commits.at(-1)!.id}${this.queryParams}`); }, /** Clicking on a single commit opens this specific commit */ commitClicked(commitId: string, newWindow = false) { @@ -193,7 +193,7 @@ export default defineComponent({ // find all selected commits and generate a link const firstSelected = this.commits.findIndex((x) => x.selected); const lastSelected = this.commits.findLastIndex((x) => x.selected); - let beforeCommitID: string; + let beforeCommitID: string | null = null; if (firstSelected === 0) { beforeCommitID = this.mergeBase; } else { @@ -204,7 +204,7 @@ export default defineComponent({ if (firstSelected === lastSelected) { // if the start and end are the same, we show this single commit window.location.assign(`${this.issueLink}/commits/${afterCommitID}${this.queryParams}`); - } else if (beforeCommitID === this.mergeBase && afterCommitID === this.commits.at(-1).id) { + } else if (beforeCommitID === this.mergeBase && afterCommitID === this.commits.at(-1)!.id) { // if the first commit is selected and the last commit is selected, we show all commits window.location.assign(`${this.issueLink}/files${this.queryParams}`); } else { diff --git a/web_src/js/components/DiffFileTree.vue b/web_src/js/components/DiffFileTree.vue index 981d10c1c1..e2934b967e 100644 --- a/web_src/js/components/DiffFileTree.vue +++ b/web_src/js/components/DiffFileTree.vue @@ -12,14 +12,14 @@ const store = diffTreeStore(); onMounted(() => { // Default to true if unset store.fileTreeIsVisible = localStorage.getItem(LOCAL_STORAGE_KEY) !== 'false'; - document.querySelector('.diff-toggle-file-tree-button').addEventListener('click', toggleVisibility); + document.querySelector('.diff-toggle-file-tree-button')!.addEventListener('click', toggleVisibility); hashChangeListener(); window.addEventListener('hashchange', hashChangeListener); }); onUnmounted(() => { - document.querySelector('.diff-toggle-file-tree-button').removeEventListener('click', toggleVisibility); + document.querySelector('.diff-toggle-file-tree-button')!.removeEventListener('click', toggleVisibility); window.removeEventListener('hashchange', hashChangeListener); }); @@ -33,7 +33,7 @@ function expandSelectedFile() { if (store.selectedItem) { const box = document.querySelector(store.selectedItem); const folded = box?.getAttribute('data-folded') === 'true'; - if (folded) setFileFolding(box, box.querySelector('.fold-file'), false); + if (folded) setFileFolding(box, box.querySelector('.fold-file')!, false); } } @@ -48,10 +48,10 @@ function updateVisibility(visible: boolean) { } function updateState(visible: boolean) { - const btn = document.querySelector('.diff-toggle-file-tree-button'); + const btn = document.querySelector('.diff-toggle-file-tree-button')!; const [toShow, toHide] = btn.querySelectorAll('.icon'); - const tree = document.querySelector('#diff-file-tree'); - const newTooltip = btn.getAttribute(visible ? 'data-hide-text' : 'data-show-text'); + const tree = document.querySelector('#diff-file-tree')!; + const newTooltip = btn.getAttribute(visible ? 'data-hide-text' : 'data-show-text')!; btn.setAttribute('data-tooltip-content', newTooltip); toggleElem(tree, visible); toggleElem(toShow, !visible); diff --git a/web_src/js/components/RepoActionView.vue b/web_src/js/components/RepoActionView.vue index 00748ee9bb..357a2ba10e 100644 --- a/web_src/js/components/RepoActionView.vue +++ b/web_src/js/components/RepoActionView.vue @@ -402,7 +402,7 @@ export default defineComponent({ } // auto-scroll to the last log line of the last step - let autoScrollJobStepElement: HTMLElement; + let autoScrollJobStepElement: HTMLElement | undefined; for (let stepIndex = 0; stepIndex < this.currentJob.steps.length; stepIndex++) { if (!autoScrollStepIndexes.get(stepIndex)) continue; autoScrollJobStepElement = this.getJobStepLogsContainer(stepIndex); @@ -468,7 +468,7 @@ export default defineComponent({ } const logLine = this.elStepsContainer().querySelector(selectedLogStep); if (!logLine) return; - logLine.querySelector('.line-num').click(); + logLine.querySelector('.line-num')!.click(); }, }, }); diff --git a/web_src/js/components/RepoActivityTopAuthors.vue b/web_src/js/components/RepoActivityTopAuthors.vue index 5a925f9943..1d04fa5239 100644 --- a/web_src/js/components/RepoActivityTopAuthors.vue +++ b/web_src/js/components/RepoActivityTopAuthors.vue @@ -1,7 +1,7 @@ + + + + diff --git a/web_src/js/components/ViewFileTree.vue b/web_src/js/components/ViewFileTree.vue index 1f90f92586..dc131383f6 100644 --- a/web_src/js/components/ViewFileTree.vue +++ b/web_src/js/components/ViewFileTree.vue @@ -1,9 +1,9 @@