diff --git a/models/repo/repo.go b/models/repo/repo.go index 2977dfb9f1d8a..6e2727f7e76e6 100644 --- a/models/repo/repo.go +++ b/models/repo/repo.go @@ -644,7 +644,7 @@ func (repo *Repository) AllowsPulls(ctx context.Context) bool { // CanEnableEditor returns true if repository meets the requirements of web editor. func (repo *Repository) CanEnableEditor() bool { - return !repo.IsMirror + return !repo.IsMirror && !repo.IsArchived } // DescriptionHTML does special handles to description and return HTML string. diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 54089be24a1c2..8848afc2f37e5 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -1334,9 +1334,9 @@ editor.cannot_edit_non_text_files = Binary files cannot be edited in the web int editor.edit_this_file = Edit File 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.must_have_write_access = You must have write access to make or propose changes to this file. +editor.must_be_signed_in = You must be signed in to make or propose changes. editor.file_delete_success = File "%s" has been deleted. 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. @@ -1394,6 +1394,16 @@ editor.user_no_push_to_branch = User cannot push to branch editor.require_signed_commit = Branch requires a signed commit editor.cherry_pick = Cherry-pick %s onto: editor.revert = Revert %s onto: +editor.fork_create = Fork Repository to Propose Changes +editor.fork_create_description = You can not edit this repository directly. Instead you can create a fork, make edits and create a pull request. +editor.fork_edit_description = You can not edit this repository directly. The changes will be written to your fork %s, so you can create a pull request. +editor.fork_failed_to_push_branch = Failed to push branch %s to your repoitory. +editor.cannot_find_editable_repo = Can not find repository to apply the edit to. Was it deleted while editing? +editor.fork_not_editable = Fork Repository Not Editable +editor.fork_internal_error = Internal error loading repository information about %s. +editor.fork_is_archived = Your repository %s is archived. Unarchive it in repository settings to make changes. +editor.fork_code_disabled = Code is disabled in your repository %s. Enable code in repository settings to make changes. +editor.fork_no_permission = You do not have permission to write to repository %s. commits.desc = Browse source code change history. commits.commits = Commits diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index b9b590725b42b..5801a5730749f 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -434,7 +434,7 @@ func reqRepoWriter(unitTypes ...unit.Type) func(ctx *context.APIContext) { // reqRepoBranchWriter user should have a permission to write to a branch, or be a site admin func reqRepoBranchWriter(ctx *context.APIContext) { options, ok := web.GetForm(ctx).(api.FileOptionInterface) - if !ok || (!ctx.Repo.CanWriteToBranch(ctx, ctx.Doer, options.Branch()) && !ctx.IsUserSiteAdmin()) { + if !ok || (!context.CanWriteToBranch(ctx, ctx.Doer, ctx.Repo.Repository, options.Branch()) && !ctx.IsUserSiteAdmin()) { ctx.APIError(http.StatusForbidden, "user should have a permission to write to this branch") return } diff --git a/routers/api/v1/repo/file.go b/routers/api/v1/repo/file.go index d8e9fde2c006b..fab0a839cf048 100644 --- a/routers/api/v1/repo/file.go +++ b/routers/api/v1/repo/file.go @@ -405,7 +405,7 @@ func GetEditorconfig(ctx *context.APIContext) { // canWriteFiles returns true if repository is editable and user has proper access level. func canWriteFiles(ctx *context.APIContext, branch string) bool { - return ctx.Repo.CanWriteToBranch(ctx, ctx.Doer, branch) && + return context.CanWriteToBranch(ctx, ctx.Doer, ctx.Repo.Repository, branch) && !ctx.Repo.Repository.IsMirror && !ctx.Repo.Repository.IsArchived } diff --git a/routers/web/repo/cherry_pick.go b/routers/web/repo/cherry_pick.go index 690b830bc2f2a..bc1fd1c14e69f 100644 --- a/routers/web/repo/cherry_pick.go +++ b/routers/web/repo/cherry_pick.go @@ -47,7 +47,7 @@ func CherryPick(ctx *context.Context) { ctx.Data["commit_message"] = splits[1] } - canCommit := renderCommitRights(ctx) + canCommit := renderCommitRights(ctx, ctx.Repo.Repository) ctx.Data["TreePath"] = "" if canCommit { @@ -55,7 +55,7 @@ func CherryPick(ctx *context.Context) { } else { ctx.Data["commit_choice"] = frmCommitChoiceNewBranch } - ctx.Data["new_branch_name"] = GetUniquePatchBranchName(ctx) + ctx.Data["new_branch_name"] = GetUniquePatchBranchName(ctx, ctx.Repo.Repository) ctx.Data["last_commit"] = ctx.Repo.CommitID ctx.Data["LineWrapExtensions"] = strings.Join(setting.Repository.Editor.LineWrapExtensions, ",") ctx.Data["BranchLink"] = ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL() @@ -75,7 +75,7 @@ func CherryPickPost(ctx *context.Context) { ctx.Data["CherryPickType"] = "cherry-pick" } - canCommit := renderCommitRights(ctx) + canCommit := renderCommitRights(ctx, ctx.Repo.Repository) branchName := ctx.Repo.BranchName if form.CommitChoice == frmCommitChoiceNewBranch { branchName = form.NewBranchName diff --git a/routers/web/repo/editor.go b/routers/web/repo/editor.go index c925b6115147a..ec6b9c37d2bc8 100644 --- a/routers/web/repo/editor.go +++ b/routers/web/repo/editor.go @@ -1,4 +1,5 @@ // Copyright 2016 The Gogs Authors. All rights reserved. +// Copyright 2025 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package repo @@ -40,37 +41,47 @@ const ( frmCommitChoiceNewBranch string = "commit-to-new-branch" ) -func canCreateBasePullRequest(ctx *context.Context) bool { - baseRepo := ctx.Repo.Repository.BaseRepo +func canCreateBasePullRequest(ctx *context.Context, editRepo *repo_model.Repository) bool { + baseRepo := editRepo.BaseRepo return baseRepo != nil && baseRepo.UnitEnabled(ctx, unit.TypePullRequests) } -func renderCommitRights(ctx *context.Context) bool { - canCommitToBranch, err := ctx.Repo.CanCommitToBranch(ctx, ctx.Doer) +func renderCommitRights(ctx *context.Context, editRepo *repo_model.Repository) bool { + canCommitToBranch, err := context.CanCommitToBranch(ctx, ctx.Doer, editRepo, ctx.Repo.BranchName) if err != nil { log.Error("CanCommitToBranch: %v", err) } + + if editRepo.ID == ctx.Repo.Repository.ID { + // Editing the same repository that we are viewing + ctx.Data["CanCreatePullRequest"] = ctx.Repo.Repository.UnitEnabled(ctx, unit.TypePullRequests) || canCreateBasePullRequest(ctx, editRepo) + } else { + // Editing a user fork of the repository we are viewing, always choose a new branch + canCommitToBranch.CanCommitToBranch = false + canCommitToBranch.UserCanPush = false + ctx.Data["CanCreatePullRequest"] = canCreateBasePullRequest(ctx, editRepo) + } + ctx.Data["CanCommitToBranch"] = canCommitToBranch - ctx.Data["CanCreatePullRequest"] = ctx.Repo.Repository.UnitEnabled(ctx, unit.TypePullRequests) || canCreateBasePullRequest(ctx) return canCommitToBranch.CanCommitToBranch } // redirectForCommitChoice redirects after committing the edit to a branch -func redirectForCommitChoice(ctx *context.Context, commitChoice, newBranchName, treePath string) { +func redirectForCommitChoice(ctx *context.Context, editRepo *repo_model.Repository, commitChoice, newBranchName, treePath string) { if commitChoice == frmCommitChoiceNewBranch { // Redirect to a pull request when possible redirectToPullRequest := false - repo := ctx.Repo.Repository + repo := editRepo baseBranch := ctx.Repo.BranchName headBranch := newBranchName - if repo.UnitEnabled(ctx, unit.TypePullRequests) { - redirectToPullRequest = true - } else if canCreateBasePullRequest(ctx) { + if canCreateBasePullRequest(ctx, repo) { redirectToPullRequest = true baseBranch = repo.BaseRepo.DefaultBranch headBranch = repo.Owner.Name + "/" + repo.Name + ":" + headBranch repo = repo.BaseRepo + } else if repo.UnitEnabled(ctx, unit.TypePullRequests) { + redirectToPullRequest = true } if redirectToPullRequest { @@ -83,7 +94,7 @@ func redirectForCommitChoice(ctx *context.Context, commitChoice, newBranchName, ctx.RedirectToCurrentSite( returnURI, - ctx.Repo.RepoLink+"/src/branch/"+util.PathEscapeSegments(newBranchName)+"/"+util.PathEscapeSegments(treePath), + editRepo.Link()+"/src/branch/"+util.PathEscapeSegments(newBranchName)+"/"+util.PathEscapeSegments(treePath), ) } @@ -113,8 +124,14 @@ func editFileCommon(ctx *context.Context, isNewFile bool) { } func editFile(ctx *context.Context, isNewFile bool) { + editRepo := getEditRepositoryOrFork(ctx, util.Iif(isNewFile, "_new", "_edit")) + if editRepo == nil { + return + } + editFileCommon(ctx, isNewFile) - canCommit := renderCommitRights(ctx) + + canCommit := renderCommitRights(ctx, editRepo) treePath := cleanUploadFileName(ctx.Repo.TreePath) if treePath != ctx.Repo.TreePath { @@ -190,7 +207,7 @@ func editFile(ctx *context.Context, isNewFile bool) { ctx.Data["commit_summary"] = "" ctx.Data["commit_message"] = "" ctx.Data["commit_choice"] = util.Iif(canCommit, frmCommitChoiceDirect, frmCommitChoiceNewBranch) - ctx.Data["new_branch_name"] = GetUniquePatchBranchName(ctx) + ctx.Data["new_branch_name"] = GetUniquePatchBranchName(ctx, editRepo) ctx.Data["last_commit"] = ctx.Repo.CommitID ctx.Data["EditorconfigJson"] = GetEditorConfig(ctx, treePath) @@ -223,9 +240,9 @@ func NewFile(ctx *context.Context) { func editFilePost(ctx *context.Context, form forms.EditRepoFileForm, isNewFile bool) { editFileCommon(ctx, isNewFile) + ctx.Data["PageHasPosted"] = true - canCommit := renderCommitRights(ctx) treeNames, treePaths := getParentTreeFields(form.TreePath) branchName := ctx.Repo.BranchName if form.CommitChoice == frmCommitChoiceNewBranch { @@ -248,11 +265,15 @@ func editFilePost(ctx *context.Context, form forms.EditRepoFileForm, isNewFile b return } + editRepo := getEditRepositoryOrError(ctx, tplEditFile, &form) + if editRepo == nil { + return + } + + renderCommitRights(ctx, editRepo) + // Cannot commit to an existing branch if user doesn't have rights - if branchName == ctx.Repo.BranchName && !canCommit { - ctx.Data["Err_NewBranchName"] = true - ctx.Data["commit_choice"] = frmCommitChoiceNewBranch - ctx.RenderWithErr(ctx.Tr("repo.editor.cannot_commit_to_protected_branch", branchName), tplEditFile, &form) + if !canPushToEditRepository(ctx, editRepo, branchName, form.CommitChoice, tplEditFile, &form) { return } @@ -283,9 +304,14 @@ func editFilePost(ctx *context.Context, form forms.EditRepoFileForm, isNewFile b operation = "create" } - if _, err := files_service.ChangeRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, &files_service.ChangeRepoFilesOptions{ + editBranchName, err := pushToEditRepositoryOrError(ctx, editRepo, branchName, tplEditFile, &form) + if err != nil { + return + } + + if _, err := files_service.ChangeRepoFiles(ctx, editRepo, ctx.Doer, &files_service.ChangeRepoFilesOptions{ LastCommitID: form.LastCommit, - OldBranch: ctx.Repo.BranchName, + OldBranch: editBranchName, NewBranch: branchName, Message: message, Files: []*files_service.ChangeRepoFile{ @@ -377,13 +403,9 @@ func editFilePost(ctx *context.Context, form forms.EditRepoFileForm, isNewFile b } } - if ctx.Repo.Repository.IsEmpty { - if isEmpty, err := ctx.Repo.GitRepo.IsEmpty(); err == nil && !isEmpty { - _ = repo_model.UpdateRepositoryCols(ctx, &repo_model.Repository{ID: ctx.Repo.Repository.ID, IsEmpty: false}, "is_empty") - } - } + updateEditRepositoryIsEmpty(ctx, editRepo) - redirectForCommitChoice(ctx, form.CommitChoice, branchName, form.TreePath) + redirectForCommitChoice(ctx, editRepo, form.CommitChoice, branchName, form.TreePath) } // EditFilePost response for editing file @@ -431,6 +453,11 @@ func DiffPreviewPost(ctx *context.Context) { // DeleteFile render delete file page func DeleteFile(ctx *context.Context) { + editRepo := getEditRepositoryOrFork(ctx, "_delete") + if editRepo == nil { + return + } + ctx.Data["PageIsDelete"] = true ctx.Data["BranchLink"] = ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL() treePath := cleanUploadFileName(ctx.Repo.TreePath) @@ -441,7 +468,7 @@ func DeleteFile(ctx *context.Context) { } ctx.Data["TreePath"] = treePath - canCommit := renderCommitRights(ctx) + canCommit := renderCommitRights(ctx, editRepo) ctx.Data["commit_summary"] = "" ctx.Data["commit_message"] = "" @@ -451,7 +478,7 @@ func DeleteFile(ctx *context.Context) { } else { ctx.Data["commit_choice"] = frmCommitChoiceNewBranch } - ctx.Data["new_branch_name"] = GetUniquePatchBranchName(ctx) + ctx.Data["new_branch_name"] = GetUniquePatchBranchName(ctx, editRepo) ctx.HTML(http.StatusOK, tplDeleteFile) } @@ -459,7 +486,6 @@ func DeleteFile(ctx *context.Context) { // DeleteFilePost response for deleting file func DeleteFilePost(ctx *context.Context) { form := web.GetForm(ctx).(*forms.DeleteRepoFileForm) - canCommit := renderCommitRights(ctx) branchName := ctx.Repo.BranchName if form.CommitChoice == frmCommitChoiceNewBranch { branchName = form.NewBranchName @@ -479,10 +505,15 @@ func DeleteFilePost(ctx *context.Context) { return } - if branchName == ctx.Repo.BranchName && !canCommit { - ctx.Data["Err_NewBranchName"] = true - ctx.Data["commit_choice"] = frmCommitChoiceNewBranch - ctx.RenderWithErr(ctx.Tr("repo.editor.cannot_commit_to_protected_branch", branchName), tplDeleteFile, &form) + editRepo := getEditRepositoryOrError(ctx, tplDeleteFile, &form) + if editRepo == nil { + return + } + + renderCommitRights(ctx, editRepo) + + // Cannot commit to an existing branch if user doesn't have rights + if !canPushToEditRepository(ctx, editRepo, branchName, form.CommitChoice, tplDeleteFile, &form) { return } @@ -502,9 +533,14 @@ func DeleteFilePost(ctx *context.Context) { return } - if _, err := files_service.ChangeRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, &files_service.ChangeRepoFilesOptions{ + editBranchName, err := pushToEditRepositoryOrError(ctx, editRepo, branchName, tplDeleteFile, &form) + if err != nil { + return + } + + if _, err := files_service.ChangeRepoFiles(ctx, editRepo, ctx.Doer, &files_service.ChangeRepoFilesOptions{ LastCommitID: form.LastCommit, - OldBranch: ctx.Repo.BranchName, + OldBranch: editBranchName, NewBranch: branchName, Files: []*files_service.ChangeRepoFile{ { @@ -594,14 +630,19 @@ func DeleteFilePost(ctx *context.Context) { } } - redirectForCommitChoice(ctx, form.CommitChoice, branchName, treePath) + redirectForCommitChoice(ctx, editRepo, form.CommitChoice, branchName, treePath) } // UploadFile render upload file page func UploadFile(ctx *context.Context) { + editRepo := getEditRepositoryOrFork(ctx, "_upload") + if editRepo == nil { + return + } + ctx.Data["PageIsUpload"] = true upload.AddUploadContext(ctx, "repo") - canCommit := renderCommitRights(ctx) + canCommit := renderCommitRights(ctx, editRepo) treePath := cleanUploadFileName(ctx.Repo.TreePath) if treePath != ctx.Repo.TreePath { ctx.Redirect(path.Join(ctx.Repo.RepoLink, "_upload", util.PathEscapeSegments(ctx.Repo.BranchName), util.PathEscapeSegments(treePath))) @@ -625,7 +666,7 @@ func UploadFile(ctx *context.Context) { } else { ctx.Data["commit_choice"] = frmCommitChoiceNewBranch } - ctx.Data["new_branch_name"] = GetUniquePatchBranchName(ctx) + ctx.Data["new_branch_name"] = GetUniquePatchBranchName(ctx, editRepo) ctx.HTML(http.StatusOK, tplUploadFile) } @@ -635,11 +676,8 @@ func UploadFilePost(ctx *context.Context) { form := web.GetForm(ctx).(*forms.UploadRepoFileForm) ctx.Data["PageIsUpload"] = true upload.AddUploadContext(ctx, "repo") - canCommit := renderCommitRights(ctx) - - oldBranchName := ctx.Repo.BranchName - branchName := oldBranchName + branchName := ctx.Repo.BranchName if form.CommitChoice == frmCommitChoiceNewBranch { branchName = form.NewBranchName } @@ -666,16 +704,14 @@ func UploadFilePost(ctx *context.Context) { return } - if oldBranchName != branchName { - if exist, err := git_model.IsBranchExist(ctx, ctx.Repo.Repository.ID, branchName); err == nil && exist { - ctx.Data["Err_NewBranchName"] = true - ctx.RenderWithErr(ctx.Tr("repo.editor.branch_already_exists", branchName), tplUploadFile, &form) - return - } - } else if !canCommit { - ctx.Data["Err_NewBranchName"] = true - ctx.Data["commit_choice"] = frmCommitChoiceNewBranch - ctx.RenderWithErr(ctx.Tr("repo.editor.cannot_commit_to_protected_branch", branchName), tplUploadFile, &form) + editRepo := getEditRepositoryOrError(ctx, tplUploadFile, &form) + if editRepo == nil { + return + } + + renderCommitRights(ctx, editRepo) + + if !canPushToEditRepository(ctx, editRepo, branchName, form.CommitChoice, tplUploadFile, &form) { return } @@ -722,9 +758,14 @@ func UploadFilePost(ctx *context.Context) { return } - if err := files_service.UploadRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, &files_service.UploadRepoFileOptions{ + editBranchName, err := pushToEditRepositoryOrError(ctx, editRepo, branchName, tplUploadFile, &form) + if err != nil { + return + } + + if err := files_service.UploadRepoFiles(ctx, editRepo, ctx.Doer, &files_service.UploadRepoFileOptions{ LastCommitID: ctx.Repo.CommitID, - OldBranch: oldBranchName, + OldBranch: editBranchName, NewBranch: branchName, TreePath: form.TreePath, Message: message, @@ -783,19 +824,15 @@ func UploadFilePost(ctx *context.Context) { } } else { // os.ErrNotExist - upload file missing in the intervening time?! - log.Error("Error during upload to repo: %-v to filepath: %s on %s from %s: %v", ctx.Repo.Repository, form.TreePath, oldBranchName, form.NewBranchName, err) + log.Error("Error during upload to repo: %-v to filepath: %s on %s from %s: %v", editRepo, form.TreePath, editBranchName, form.NewBranchName, err) ctx.RenderWithErr(ctx.Tr("repo.editor.unable_to_upload_files", form.TreePath, err), tplUploadFile, &form) } return } - if ctx.Repo.Repository.IsEmpty { - if isEmpty, err := ctx.Repo.GitRepo.IsEmpty(); err == nil && !isEmpty { - _ = repo_model.UpdateRepositoryCols(ctx, &repo_model.Repository{ID: ctx.Repo.Repository.ID, IsEmpty: false}, "is_empty") - } - } + updateEditRepositoryIsEmpty(ctx, editRepo) - redirectForCommitChoice(ctx, form.CommitChoice, branchName, form.TreePath) + redirectForCommitChoice(ctx, editRepo, form.CommitChoice, branchName, form.TreePath) } func cleanUploadFileName(name string) string { @@ -811,6 +848,7 @@ func cleanUploadFileName(name string) string { } // UploadFileToServer upload file to server file dir not git +// This is independent of any repository, no repository permissions are checked to call this. func UploadFileToServer(ctx *context.Context) { file, header, err := ctx.Req.FormFile("file") if err != nil { @@ -850,6 +888,7 @@ func UploadFileToServer(ctx *context.Context) { } // RemoveUploadFileFromServer remove file from server file dir +// This is independent of any repository, no repository permissions are checked to call this. func RemoveUploadFileFromServer(ctx *context.Context) { form := web.GetForm(ctx).(*forms.RemoveUploadFileForm) if len(form.File) == 0 { @@ -870,11 +909,11 @@ func RemoveUploadFileFromServer(ctx *context.Context) { // It will be in the form of -patch- where is the first branch of this format // that doesn't already exist. If we exceed 1000 tries or an error is thrown, we just return "" so the user has to // type in the branch name themselves (will be an empty field) -func GetUniquePatchBranchName(ctx *context.Context) string { +func GetUniquePatchBranchName(ctx *context.Context, editRepo *repo_model.Repository) string { prefix := ctx.Doer.LowerName + "-patch-" for i := 1; i <= 1000; i++ { branchName := fmt.Sprintf("%s%d", prefix, i) - if exist, err := git_model.IsBranchExist(ctx, ctx.Repo.Repository.ID, branchName); err != nil { + if exist, err := git_model.IsBranchExist(ctx, editRepo.ID, branchName); err != nil { log.Error("GetUniquePatchBranchName: %v", err) return "" } else if !exist { diff --git a/routers/web/repo/editor_test.go b/routers/web/repo/editor_test.go index 89bf8f309cd5e..c54648c51ed3a 100644 --- a/routers/web/repo/editor_test.go +++ b/routers/web/repo/editor_test.go @@ -51,7 +51,7 @@ func TestGetUniquePatchBranchName(t *testing.T) { defer ctx.Repo.GitRepo.Close() expectedBranchName := "user2-patch-1" - branchName := GetUniquePatchBranchName(ctx) + branchName := GetUniquePatchBranchName(ctx, ctx.Repo.Repository) assert.Equal(t, expectedBranchName, branchName) } diff --git a/routers/web/repo/fork.go b/routers/web/repo/fork.go index 79f033659b46c..699e67bd0967f 100644 --- a/routers/web/repo/fork.go +++ b/routers/web/repo/fork.go @@ -155,17 +155,30 @@ func ForkPost(ctx *context.Context) { return } + repo := forkRepositoryOrError(ctx, ctxUser, repo_service.ForkRepoOptions{ + BaseRepo: forkRepo, + Name: form.RepoName, + Description: form.Description, + SingleBranch: form.ForkSingleBranch, + }, tplFork, form) + if repo == nil { + return + } + + ctx.Redirect(ctxUser.HomeLink() + "/" + url.PathEscape(repo.Name)) +} + +func forkRepositoryOrError(ctx *context.Context, user *user_model.User, opts repo_service.ForkRepoOptions, tpl templates.TplName, form any) *repo_model.Repository { var err error - traverseParentRepo := forkRepo + traverseParentRepo := opts.BaseRepo for { - if !repository.CanUserForkBetweenOwners(ctxUser.ID, traverseParentRepo.OwnerID) { - ctx.RenderWithErr(ctx.Tr("repo.settings.new_owner_has_same_repo"), tplFork, &form) - return + if !repository.CanUserForkBetweenOwners(user.ID, traverseParentRepo.OwnerID) { + ctx.RenderWithErr(ctx.Tr("repo.settings.new_owner_has_same_repo"), tpl, form) + return nil } - repo := repo_model.GetForkedRepo(ctx, ctxUser.ID, traverseParentRepo.ID) + repo := repo_model.GetForkedRepo(ctx, user.ID, traverseParentRepo.ID) if repo != nil { - ctx.Redirect(ctxUser.HomeLink() + "/" + url.PathEscape(repo.Name)) - return + return repo } if !traverseParentRepo.IsFork { break @@ -173,60 +186,55 @@ func ForkPost(ctx *context.Context) { traverseParentRepo, err = repo_model.GetRepositoryByID(ctx, traverseParentRepo.ForkID) if err != nil { ctx.ServerError("GetRepositoryByID", err) - return + return nil } } // Check if user is allowed to create repo's on the organization. - if ctxUser.IsOrganization() { - isAllowedToFork, err := organization.OrgFromUser(ctxUser).CanCreateOrgRepo(ctx, ctx.Doer.ID) + if user.IsOrganization() { + isAllowedToFork, err := organization.OrgFromUser(user).CanCreateOrgRepo(ctx, ctx.Doer.ID) if err != nil { ctx.ServerError("CanCreateOrgRepo", err) - return + return nil } else if !isAllowedToFork { ctx.HTTPError(http.StatusForbidden) - return + return nil } } - repo, err := repo_service.ForkRepository(ctx, ctx.Doer, ctxUser, repo_service.ForkRepoOptions{ - BaseRepo: forkRepo, - Name: form.RepoName, - Description: form.Description, - SingleBranch: form.ForkSingleBranch, - }) + repo, err := repo_service.ForkRepository(ctx, ctx.Doer, user, opts) if err != nil { ctx.Data["Err_RepoName"] = true switch { case repo_model.IsErrReachLimitOfRepo(err): - maxCreationLimit := ctxUser.MaxCreationLimit() + maxCreationLimit := user.MaxCreationLimit() msg := ctx.TrN(maxCreationLimit, "repo.form.reach_limit_of_creation_1", "repo.form.reach_limit_of_creation_n", maxCreationLimit) - ctx.RenderWithErr(msg, tplFork, &form) + ctx.RenderWithErr(msg, tpl, form) case repo_model.IsErrRepoAlreadyExist(err): - ctx.RenderWithErr(ctx.Tr("repo.settings.new_owner_has_same_repo"), tplFork, &form) + ctx.RenderWithErr(ctx.Tr("repo.settings.new_owner_has_same_repo"), tpl, form) case repo_model.IsErrRepoFilesAlreadyExist(err): switch { case ctx.IsUserSiteAdmin() || (setting.Repository.AllowAdoptionOfUnadoptedRepositories && setting.Repository.AllowDeleteOfUnadoptedRepositories): - ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist.adopt_or_delete"), tplFork, form) + ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist.adopt_or_delete"), tpl, form) case setting.Repository.AllowAdoptionOfUnadoptedRepositories: - ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist.adopt"), tplFork, form) + ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist.adopt"), tpl, form) case setting.Repository.AllowDeleteOfUnadoptedRepositories: - ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist.delete"), tplFork, form) + ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist.delete"), tpl, form) default: - ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist"), tplFork, form) + ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist"), tpl, form) } case db.IsErrNameReserved(err): - ctx.RenderWithErr(ctx.Tr("repo.form.name_reserved", err.(db.ErrNameReserved).Name), tplFork, &form) + ctx.RenderWithErr(ctx.Tr("repo.form.name_reserved", err.(db.ErrNameReserved).Name), tpl, form) case db.IsErrNamePatternNotAllowed(err): - ctx.RenderWithErr(ctx.Tr("repo.form.name_pattern_not_allowed", err.(db.ErrNamePatternNotAllowed).Pattern), tplFork, &form) + ctx.RenderWithErr(ctx.Tr("repo.form.name_pattern_not_allowed", err.(db.ErrNamePatternNotAllowed).Pattern), tpl, form) case errors.Is(err, user_model.ErrBlockedUser): - ctx.RenderWithErr(ctx.Tr("repo.fork.blocked_user"), tplFork, form) + ctx.RenderWithErr(ctx.Tr("repo.fork.blocked_user"), tpl, form) default: ctx.ServerError("ForkPost", err) } - return + return repo } - log.Trace("Repository forked[%d]: %s/%s", forkRepo.ID, ctxUser.Name, repo.Name) - ctx.Redirect(ctxUser.HomeLink() + "/" + url.PathEscape(repo.Name)) + log.Trace("Repository forked[%d]: %s/%s", opts.BaseRepo.ID, user.Name, repo.Name) + return repo } diff --git a/routers/web/repo/fork_to_edit.go b/routers/web/repo/fork_to_edit.go new file mode 100644 index 0000000000000..ba949391af91d --- /dev/null +++ b/routers/web/repo/fork_to_edit.go @@ -0,0 +1,241 @@ +// Copyright 2016 The Gogs Authors. All rights reserved. +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "fmt" + "net/http" + "path" + "strings" + + git_model "code.gitea.io/gitea/models/git" + access_model "code.gitea.io/gitea/models/perm/access" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/modules/log" + repo_module "code.gitea.io/gitea/modules/repository" + "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" + repo_service "code.gitea.io/gitea/services/repository" +) + +const ( + tplForkFile templates.TplName = "repo/editor/fork" +) + +// getEditRepository returns the repository where the actual edits will be written to. +// This may be a fork of the repository owned by the user. If no repository can be found +// for editing, nil is returned along with a message explaining why editing is not possible. +func getEditRepository(ctx *context.Context) (*repo_model.Repository, any) { + if context.CanWriteToBranch(ctx, ctx.Doer, ctx.Repo.Repository, ctx.Repo.BranchName) { + return ctx.Repo.Repository, nil + } + + // If we can't write to the branch, try find a user fork to create a branch in instead + userRepo, err := repo_model.GetUserFork(ctx, ctx.Repo.Repository.ID, ctx.Doer.ID) + if err != nil { + log.Error("GetUserFork: %v", err) + return nil, nil + } + if userRepo == nil { + return nil, nil + } + + // Load repository information + if err := userRepo.LoadOwner(ctx); err != nil { + log.Error("LoadOwner: %v", err) + return nil, ctx.Tr("repo.editor.fork_internal_error", userRepo.FullName()) + } + if err := userRepo.GetBaseRepo(ctx); err != nil || userRepo.BaseRepo == nil { + if err != nil { + log.Error("GetBaseRepo: %v", err) + } else { + log.Error("GetBaseRepo: Expected a base repo for user fork", err) + } + return nil, ctx.Tr("repo.editor.fork_internal_error", userRepo.FullName()) + } + + // Check code unit, archiving and permissions. + if !userRepo.UnitEnabled(ctx, unit.TypeCode) { + return nil, ctx.Tr("repo.editor.fork_code_disabled", userRepo.FullName()) + } + if userRepo.IsArchived { + return nil, ctx.Tr("repo.editor.fork_is_archived", userRepo.FullName()) + } + permission, err := access_model.GetUserRepoPermission(ctx, userRepo, ctx.Doer) + if err != nil { + log.Error("access_model.GetUserRepoPermission: %v", err) + return nil, ctx.Tr("repo.editor.fork_internal_error", userRepo.FullName()) + } + if !permission.CanWrite(unit.TypeCode) { + return nil, ctx.Tr("repo.editor.fork_no_permission", userRepo.FullName()) + } + + ctx.Data["ForkRepo"] = userRepo + return userRepo, nil +} + +// GetEditRepository returns the repository where the edits will be written to. +// If no repository is editable, redirects to a page to create a fork. +func getEditRepositoryOrFork(ctx *context.Context, editOperation string) *repo_model.Repository { + editRepo, notEditableMessage := getEditRepository(ctx) + if editRepo != nil { + return editRepo + } + + // No editable repository, suggest to create a fork + forkToEditFileCommon(ctx, editOperation, ctx.Repo.TreePath, notEditableMessage) + ctx.HTML(http.StatusOK, tplForkFile) + return nil +} + +// GetEditRepository returns the repository where the edits will be written to. +// If no repository is editable, display an error. +func getEditRepositoryOrError(ctx *context.Context, tpl templates.TplName, form any) *repo_model.Repository { + editRepo, _ := getEditRepository(ctx) + if editRepo == nil { + // No editable repo, maybe the fork was deleted in the meantime + ctx.RenderWithErr(ctx.Tr("repo.editor.cannot_find_editable_repo"), tpl, form) + return nil + } + return editRepo +} + +// CheckPushEditBranch chesk if pushing to the branch in the edit repository is possible, +// and if not renders an error and returns false. +func canPushToEditRepository(ctx *context.Context, editRepo *repo_model.Repository, branchName, commitChoice string, tpl templates.TplName, form any) bool { + // When pushing to a fork or chosing to commit to a new branch, it should not exist yet + if editRepo.ID != ctx.Repo.Repository.ID || commitChoice == frmCommitChoiceNewBranch { + if exist, err := git_model.IsBranchExist(ctx, editRepo.ID, branchName); err == nil && exist { + ctx.Data["Err_NewBranchName"] = true + ctx.RenderWithErr(ctx.Tr("repo.editor.branch_already_exists", branchName), tpl, form) + return false + } + } + + // Check for protected branch + canCommitToBranch, err := context.CanCommitToBranch(ctx, ctx.Doer, editRepo, branchName) + if err != nil { + log.Error("CanCommitToBranch: %v", err) + } + if !canCommitToBranch.CanCommitToBranch { + ctx.Data["Err_NewBranchName"] = true + ctx.RenderWithErr(ctx.Tr("repo.editor.cannot_commit_to_protected_branch", branchName), tpl, form) + return false + } + + return true +} + +// pushToEditRepositoryOrError pushes the branch that editing will be applied on top of +// to the user fork, if needed. On failure, it displays and returns an error. The +// branch name to be used for editing is returned. +func pushToEditRepositoryOrError(ctx *context.Context, editRepo *repo_model.Repository, branchName string, tpl templates.TplName, form any) (string, error) { + // If editing the same repository, no need to push anything + if editRepo.ID == ctx.Repo.Repository.ID { + return ctx.Repo.BranchName, nil + } + + // If editing a user fork, first push the branch to that repository + baseRepo := ctx.Repo.Repository + baseBranchName := ctx.Repo.BranchName + + log.Trace("pushBranchToUserRepo: pushing branch to user repo for editing: %s:%s %s:%s", baseRepo.FullName(), baseBranchName, editRepo.FullName(), branchName) + + if err := git.Push(ctx, baseRepo.RepoPath(), git.PushOptions{ + Remote: editRepo.RepoPath(), + Branch: baseBranchName + ":" + branchName, + Env: repo_module.PushingEnvironment(ctx.Doer, editRepo), + }); err != nil { + ctx.RenderWithErr(ctx.Tr("repo.editor.fork_failed_to_push_branch", branchName), tpl, form) + return "", err + } + + return branchName, nil +} + +// updateEditRepositoryIsEmpty updates the the edit repository to mark it as no longer empty +func updateEditRepositoryIsEmpty(ctx *context.Context, editRepo *repo_model.Repository) { + if !editRepo.IsEmpty { + return + } + + editGitRepo, err := gitrepo.OpenRepository(git.DefaultContext, editRepo) + if err != nil { + log.Error("gitrepo.OpenRepository: %v", err) + return + } + if editGitRepo == nil { + return + } + + if isEmpty, err := editGitRepo.IsEmpty(); err == nil && !isEmpty { + _ = repo_model.UpdateRepositoryCols(ctx, &repo_model.Repository{ID: editRepo.ID, IsEmpty: false}, "is_empty") + } + editGitRepo.Close() +} + +func forkToEditFileCommon(ctx *context.Context, editOperation, treePath string, notEditableMessage any) { + // Check if the filename (and additional path) is specified in the querystring + // (filename is a misnomer, but kept for compatibility with GitHub) + filePath, _ := path.Split(ctx.Req.URL.Query().Get("filename")) + filePath = strings.Trim(filePath, "/") + treeNames, treePaths := getParentTreeFields(path.Join(ctx.Repo.TreePath, filePath)) + + ctx.Data["EditOperation"] = editOperation + ctx.Data["TreePath"] = treePath + ctx.Data["TreeNames"] = treeNames + ctx.Data["TreePaths"] = treePaths + ctx.Data["CanForkRepo"] = notEditableMessage == nil + ctx.Data["NotEditableMessage"] = notEditableMessage +} + +func ForkToEditFilePost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.ForkToEditRepoFileForm) + + editRepo, notEditableMessage := getEditRepository(ctx) + + ctx.Data["PageHasPosted"] = true + + // Fork repository, if it doesn't already exist + if editRepo == nil && notEditableMessage == nil { + forkRepo := forkRepositoryOrError(ctx, ctx.Doer, repo_service.ForkRepoOptions{ + BaseRepo: ctx.Repo.Repository, + Name: getUniqueRepositoryName(ctx, ctx.Repo.Repository.Name), + Description: ctx.Repo.Repository.Description, + SingleBranch: ctx.Repo.BranchName, + }, tplForkFile, form) + if forkRepo == nil { + forkToEditFileCommon(ctx, form.EditOperation, form.TreePath, notEditableMessage) + ctx.HTML(http.StatusOK, tplForkFile) + return + } + } + + // Redirect back to editing page + ctx.Redirect(path.Join(ctx.Repo.RepoLink, form.EditOperation, util.PathEscapeSegments(ctx.Repo.BranchName), util.PathEscapeSegments(form.TreePath))) +} + +// getUniqueRepositoryName Gets a unique repository name for a user +// It will append a - postfix if the name is already taken +func getUniqueRepositoryName(ctx *context.Context, name string) string { + uniqueName := name + i := 1 + + for { + _, err := repo_model.GetRepositoryByName(ctx, ctx.Doer.ID, uniqueName) + if err != nil || repo_model.IsErrRepoNotExist(err) { + return uniqueName + } + + uniqueName = fmt.Sprintf("%s-%d", name, i) + i++ + } +} diff --git a/routers/web/repo/patch.go b/routers/web/repo/patch.go index ca346b7e6c313..f673f0469af8d 100644 --- a/routers/web/repo/patch.go +++ b/routers/web/repo/patch.go @@ -24,7 +24,12 @@ const ( // NewDiffPatch render create patch page func NewDiffPatch(ctx *context.Context) { - canCommit := renderCommitRights(ctx) + editRepo := getEditRepositoryOrFork(ctx, "_diffpatch") + if editRepo == nil { + return + } + + canCommit := renderCommitRights(ctx, editRepo) ctx.Data["PageIsPatch"] = true @@ -35,7 +40,7 @@ func NewDiffPatch(ctx *context.Context) { } else { ctx.Data["commit_choice"] = frmCommitChoiceNewBranch } - ctx.Data["new_branch_name"] = GetUniquePatchBranchName(ctx) + ctx.Data["new_branch_name"] = GetUniquePatchBranchName(ctx, editRepo) ctx.Data["last_commit"] = ctx.Repo.CommitID ctx.Data["LineWrapExtensions"] = strings.Join(setting.Repository.Editor.LineWrapExtensions, ",") ctx.Data["BranchLink"] = ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL() @@ -47,7 +52,6 @@ func NewDiffPatch(ctx *context.Context) { func NewDiffPatchPost(ctx *context.Context) { form := web.GetForm(ctx).(*forms.EditRepoFileForm) - canCommit := renderCommitRights(ctx) branchName := ctx.Repo.BranchName if form.CommitChoice == frmCommitChoiceNewBranch { branchName = form.NewBranchName @@ -67,11 +71,15 @@ func NewDiffPatchPost(ctx *context.Context) { return } + editRepo := getEditRepositoryOrError(ctx, tplPatchFile, &form) + if editRepo == nil { + return + } + + renderCommitRights(ctx, editRepo) + // Cannot commit to an existing branch if user doesn't have rights - if branchName == ctx.Repo.BranchName && !canCommit { - ctx.Data["Err_NewBranchName"] = true - ctx.Data["commit_choice"] = frmCommitChoiceNewBranch - ctx.RenderWithErr(ctx.Tr("repo.editor.cannot_commit_to_protected_branch", branchName), tplEditFile, &form) + if !canPushToEditRepository(ctx, editRepo, branchName, form.CommitChoice, tplPatchFile, &form) { return } @@ -94,9 +102,14 @@ func NewDiffPatchPost(ctx *context.Context) { return } - fileResponse, err := files.ApplyDiffPatch(ctx, ctx.Repo.Repository, ctx.Doer, &files.ApplyDiffPatchOptions{ + editBranchName, err := pushToEditRepositoryOrError(ctx, editRepo, branchName, tplPatchFile, &form) + if err != nil { + return + } + + fileResponse, err := files.ApplyDiffPatch(ctx, editRepo, ctx.Doer, &files.ApplyDiffPatchOptions{ LastCommitID: form.LastCommit, - OldBranch: ctx.Repo.BranchName, + OldBranch: editBranchName, NewBranch: branchName, Message: message, Content: strings.ReplaceAll(form.Content, "\r", ""), @@ -119,7 +132,8 @@ func NewDiffPatchPost(ctx *context.Context) { } if form.CommitChoice == frmCommitChoiceNewBranch && ctx.Repo.Repository.UnitEnabled(ctx, unit.TypePullRequests) { - ctx.Redirect(ctx.Repo.RepoLink + "/compare/" + util.PathEscapeSegments(ctx.Repo.BranchName) + "..." + util.PathEscapeSegments(form.NewBranchName)) + editBranch := editRepo.Owner.Name + "/" + editRepo.Name + ":" + branchName + ctx.Redirect(ctx.Repo.RepoLink + "/compare/" + util.PathEscapeSegments(ctx.Repo.BranchName) + "..." + util.PathEscapeSegments(editBranch)) } else { ctx.Redirect(ctx.Repo.RepoLink + "/commit/" + fileResponse.Commit.SHA) } diff --git a/routers/web/repo/repo.go b/routers/web/repo/repo.go index ee112b83f261b..5ec475b8fec96 100644 --- a/routers/web/repo/repo.go +++ b/routers/web/repo/repo.go @@ -49,21 +49,6 @@ func MustBeNotEmpty(ctx *context.Context) { } } -// MustBeEditable check that repo can be edited -func MustBeEditable(ctx *context.Context) { - if !ctx.Repo.Repository.CanEnableEditor() { - ctx.NotFound(nil) - return - } -} - -// MustBeAbleToUpload check that repo can be uploaded to -func MustBeAbleToUpload(ctx *context.Context) { - if !setting.Repository.Upload.Enabled { - ctx.NotFound(nil) - } -} - func CommitInfoCache(ctx *context.Context) { var err error ctx.Repo.Commit, err = ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.Repository.DefaultBranch) diff --git a/routers/web/repo/view_file.go b/routers/web/repo/view_file.go index 3df6051975bc4..dd6369901fbdf 100644 --- a/routers/web/repo/view_file.go +++ b/routers/web/repo/view_file.go @@ -244,8 +244,11 @@ func prepareToRenderFile(ctx *context.Context, entry *git.TreeEntry) { ctx.Data["LineEscapeStatus"] = statuses } if !fInfo.isLFSFile { - if ctx.Repo.CanEnableEditor(ctx, ctx.Doer) { - if lfsLock != nil && lfsLock.OwnerID != ctx.Doer.ID { + if ctx.Repo.CanEnableEditor() { + if !ctx.IsSigned { + ctx.Data["CanEditFile"] = false + ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.must_be_signed_in") + } else if lfsLock != nil && lfsLock.OwnerID != ctx.Doer.ID { ctx.Data["CanEditFile"] = false ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.this_file_locked") } else { @@ -254,8 +257,6 @@ func prepareToRenderFile(ctx *context.Context, entry *git.TreeEntry) { } } else if !ctx.Repo.RefFullName.IsBranch() { ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.must_be_on_a_branch") - } else if !ctx.Repo.CanWriteToBranch(ctx, ctx.Doer, ctx.Repo.BranchName) { - ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.fork_before_edit") } } @@ -307,8 +308,11 @@ func prepareToRenderFile(ctx *context.Context, entry *git.TreeEntry) { } } - if ctx.Repo.CanEnableEditor(ctx, ctx.Doer) { - if lfsLock != nil && lfsLock.OwnerID != ctx.Doer.ID { + if ctx.Repo.CanEnableEditor() { + if !ctx.IsSigned { + ctx.Data["CanDeleteFile"] = false + ctx.Data["DeleteFileTooltip"] = ctx.Tr("repo.editor.must_be_signed_in") + } else if lfsLock != nil && lfsLock.OwnerID != ctx.Doer.ID { ctx.Data["CanDeleteFile"] = false ctx.Data["DeleteFileTooltip"] = ctx.Tr("repo.editor.this_file_locked") } else { @@ -317,7 +321,5 @@ func prepareToRenderFile(ctx *context.Context, entry *git.TreeEntry) { } } else if !ctx.Repo.RefFullName.IsBranch() { ctx.Data["DeleteFileTooltip"] = ctx.Tr("repo.editor.must_be_on_a_branch") - } else if !ctx.Repo.CanWriteToBranch(ctx, ctx.Doer, ctx.Repo.BranchName) { - ctx.Data["DeleteFileTooltip"] = ctx.Tr("repo.editor.must_have_write_access") } } diff --git a/routers/web/repo/view_readme.go b/routers/web/repo/view_readme.go index 459cf0a616232..d92e7ec5760a3 100644 --- a/routers/web/repo/view_readme.go +++ b/routers/web/repo/view_readme.go @@ -212,7 +212,15 @@ func prepareToRenderReadmeFile(ctx *context.Context, subfolder string, readmeFil ctx.Data["EscapeStatus"], ctx.Data["FileContent"] = charset.EscapeControlHTML(template.HTML(contentEscaped), ctx.Locale) } - if !fInfo.isLFSFile && ctx.Repo.CanEnableEditor(ctx, ctx.Doer) { - ctx.Data["CanEditReadmeFile"] = true + if !fInfo.isLFSFile { + if ctx.Repo.CanEnableEditor() { + if !ctx.IsSigned { + ctx.Data["CanEditReadmeFile"] = false + ctx.Data["EditReadmeFileTooltip"] = ctx.Tr("repo.editor.must_be_signed_in") + } else { + ctx.Data["CanEditReadmeFile"] = true + ctx.Data["EditReadmeFileTooltip"] = ctx.Tr("repo.editor.edit_this_file") + } + } } } diff --git a/routers/web/web.go b/routers/web/web.go index f28dc6baa4d62..73b1a7696e651 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1321,18 +1321,22 @@ func registerWebRoutes(m *web.Router) { Post(web.Bind(forms.EditRepoFileForm{}), repo.NewFilePost) m.Combo("/_delete/*").Get(repo.DeleteFile). Post(web.Bind(forms.DeleteRepoFileForm{}), repo.DeleteFilePost) - m.Combo("/_upload/*", repo.MustBeAbleToUpload).Get(repo.UploadFile). + m.Combo("/_upload/*", context.MustBeAbleToUpload()).Get(repo.UploadFile). Post(web.Bind(forms.UploadRepoFileForm{}), repo.UploadFilePost) m.Combo("/_diffpatch/*").Get(repo.NewDiffPatch). Post(web.Bind(forms.EditRepoFileForm{}), repo.NewDiffPatchPost) + m.Combo("/_fork_to_edit/*"). + Post(web.Bind(forms.ForkToEditRepoFileForm{}), repo.ForkToEditFilePost) + }, context.MustEnableEditor()) + m.Group("", func() { m.Combo("/_cherrypick/{sha:([a-f0-9]{7,64})}/*").Get(repo.CherryPick). Post(web.Bind(forms.CherryPickForm{}), repo.CherryPickPost) - }, context.RepoRefByType(git.RefTypeBranch), context.CanWriteToBranch(), repo.WebGitOperationCommonData) - m.Group("", func() { - m.Post("/upload-file", repo.UploadFileToServer) - m.Post("/upload-remove", web.Bind(forms.RemoveUploadFileForm{}), repo.RemoveUploadFileFromServer) - }, repo.MustBeAbleToUpload, reqRepoCodeWriter) - }, repo.MustBeEditable, context.RepoMustNotBeArchived()) + }, context.MustBeAbleToCherryPick()) + }, context.RepoRefByType(git.RefTypeBranch), repo.WebGitOperationCommonData) + m.Group("", func() { + m.Post("/upload-file", repo.UploadFileToServer) + m.Post("/upload-remove", web.Bind(forms.RemoveUploadFileForm{}), repo.RemoveUploadFileFromServer) + }, context.MustBeAbleToUpload()) m.Group("/branches", func() { m.Group("/_new", func() { diff --git a/services/context/permission.go b/services/context/permission.go index 7055f798da3f0..883db5e649258 100644 --- a/services/context/permission.go +++ b/services/context/permission.go @@ -21,10 +21,10 @@ func RequireRepoAdmin() func(ctx *Context) { } } -// CanWriteToBranch checks if the user is allowed to write to the branch of the repo -func CanWriteToBranch() func(ctx *Context) { +// MustBeAbleToCherryPick checks if the user is allowed to cherry-pick to a branch of the repo +func MustBeAbleToCherryPick() func(ctx *Context) { return func(ctx *Context) { - if !ctx.Repo.CanWriteToBranch(ctx, ctx.Doer, ctx.Repo.BranchName) { + if !CanWriteToBranch(ctx, ctx.Doer, ctx.Repo.Repository, ctx.Repo.BranchName) || !ctx.Repo.Repository.CanEnableEditor() { ctx.NotFound(nil) return } diff --git a/services/context/repo.go b/services/context/repo.go index 6f5c772f5e994..a7add1906fc95 100644 --- a/services/context/repo.go +++ b/services/context/repo.go @@ -67,13 +67,19 @@ type Repository struct { } // CanWriteToBranch checks if the branch is writable by the user -func (r *Repository) CanWriteToBranch(ctx context.Context, user *user_model.User, branch string) bool { - return issues_model.CanMaintainerWriteToBranch(ctx, r.Permission, branch, user) +func CanWriteToBranch(ctx context.Context, user *user_model.User, repo *repo_model.Repository, branch string) bool { + permission, err := access_model.GetUserRepoPermission(ctx, repo, user) + if err != nil { + return false + } + + return issues_model.CanMaintainerWriteToBranch(ctx, permission, branch, user) } -// CanEnableEditor returns true if repository is editable and user has proper access level. -func (r *Repository) CanEnableEditor(ctx context.Context, user *user_model.User) bool { - return r.RefFullName.IsBranch() && r.CanWriteToBranch(ctx, user, r.BranchName) && r.Repository.CanEnableEditor() && !r.Repository.IsArchived +// CanEnableEditor returns true if the web editor can be enabled for this repository, +// either by directly writing to the repository or to a user fork. +func (r *Repository) CanEnableEditor() bool { + return r.RefFullName.IsBranch() && r.Repository.CanEnableEditor() } // CanCreateBranch returns true if repository is editable and user has proper access level. @@ -94,10 +100,27 @@ func RepoMustNotBeArchived() func(ctx *Context) { } } +// MustEnableEditor checks if the web editor can be enabled for this repository +func MustEnableEditor() func(ctx *Context) { + return func(ctx *Context) { + if !ctx.Repo.CanEnableEditor() { + ctx.NotFound(nil) + } + } +} + +// MustBeAbleToUpload check that upload is enabled on this site and useful for editing +func MustBeAbleToUpload() func(ctx *Context) { + return func(ctx *Context) { + if !setting.Repository.Upload.Enabled || !ctx.Repo.Repository.CanEnableEditor() { + ctx.NotFound(nil) + } + } +} + // CanCommitToBranchResults represents the results of CanCommitToBranch type CanCommitToBranchResults struct { CanCommitToBranch bool - EditorEnabled bool UserCanPush bool RequireSigned bool WillSign bool @@ -106,24 +129,23 @@ type CanCommitToBranchResults struct { } // CanCommitToBranch returns true if repository is editable and user has proper access level -// // and branch is not protected for push -func (r *Repository) CanCommitToBranch(ctx context.Context, doer *user_model.User) (CanCommitToBranchResults, error) { - protectedBranch, err := git_model.GetFirstMatchProtectedBranchRule(ctx, r.Repository.ID, r.BranchName) +func CanCommitToBranch(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, branchName string) (CanCommitToBranchResults, error) { + protectedBranch, err := git_model.GetFirstMatchProtectedBranchRule(ctx, repo.ID, branchName) if err != nil { return CanCommitToBranchResults{}, err } userCanPush := true requireSigned := false if protectedBranch != nil { - protectedBranch.Repo = r.Repository + protectedBranch.Repo = repo userCanPush = protectedBranch.CanUserPush(ctx, doer) requireSigned = protectedBranch.RequireSignedCommits } - sign, keyID, _, err := asymkey_service.SignCRUDAction(ctx, r.Repository.RepoPath(), doer, r.Repository.RepoPath(), git.BranchPrefix+r.BranchName) + sign, keyID, _, err := asymkey_service.SignCRUDAction(ctx, repo.RepoPath(), doer, repo.RepoPath(), git.BranchPrefix+branchName) - canCommit := r.CanEnableEditor(ctx, doer) && userCanPush + canCommit := repo.CanEnableEditor() && CanWriteToBranch(ctx, doer, repo, branchName) && userCanPush if requireSigned { canCommit = canCommit && sign } @@ -139,7 +161,6 @@ func (r *Repository) CanCommitToBranch(ctx context.Context, doer *user_model.Use return CanCommitToBranchResults{ CanCommitToBranch: canCommit, - EditorEnabled: r.CanEnableEditor(ctx, doer), UserCanPush: userCanPush, RequireSigned: requireSigned, WillSign: sign, diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index 434274c174bed..b5d72c7fb299f 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -733,6 +733,18 @@ func (f *EditPreviewDiffForm) Validate(req *http.Request, errs binding.Errors) b return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } +// ForkToEditRepoFileForm form for forking the repo to edit a file +type ForkToEditRepoFileForm struct { + TreePath string `binding:"Required;MaxSize(500)"` + EditOperation string `binding:"Required;MaxSize(20)"` +} + +// Validate validates the fields +func (f *ForkToEditRepoFileForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { + ctx := context.GetValidateContext(req) + return middleware.Validate(errs, ctx.Data, f, ctx.Locale) +} + // _________ .__ __________.__ __ // \_ ___ \| |__ __________________ ___.__. \______ \__| ____ | | __ // / \ \/| | \_/ __ \_ __ \_ __ < | | | ___/ |/ ___\| |/ / diff --git a/templates/repo/editor/delete.tmpl b/templates/repo/editor/delete.tmpl index 2c0c2fc792e07..4e532e94a0970 100644 --- a/templates/repo/editor/delete.tmpl +++ b/templates/repo/editor/delete.tmpl @@ -5,6 +5,11 @@ {{template "base/alert" .}} {{.CsrfTokenHtml}} + {{if .ForkRepo}} + + {{ctx.Locale.Tr "repo.editor.fork_edit_description" .ForkRepo.FullName}} + + {{end}} {{template "repo/editor/commit_form" .}} diff --git a/templates/repo/editor/edit.tmpl b/templates/repo/editor/edit.tmpl index ae8a60c20c116..16a5988716327 100644 --- a/templates/repo/editor/edit.tmpl +++ b/templates/repo/editor/edit.tmpl @@ -10,6 +10,11 @@ {{.CsrfTokenHtml}} + {{if .ForkRepo}} + + {{ctx.Locale.Tr "repo.editor.fork_edit_description" .ForkRepo.FullName}} + + {{end}} {{.Repository.Name}} diff --git a/templates/repo/editor/fork.tmpl b/templates/repo/editor/fork.tmpl new file mode 100644 index 0000000000000..f89bf2f3f2f72 --- /dev/null +++ b/templates/repo/editor/fork.tmpl @@ -0,0 +1,41 @@ +{{template "base/head" .}} + + {{template "repo/header" .}} + + {{template "base/alert" .}} + + {{.CsrfTokenHtml}} + + + + {{.Repository.Name}} + {{$n := len .TreeNames}} + {{$l := Eval $n "-" 1}} + {{range $i, $v := .TreeNames}} + / + {{if eq $i $l}} + {{$v}} + {{else}} + {{$v}} + {{end}} + {{end}} + + + + + + {{if .CanForkRepo}} + {{ctx.Locale.Tr "repo.editor.fork_create"}} + {{ctx.Locale.Tr "repo.editor.fork_create_description"}} + + {{ctx.Locale.Tr "repo.fork_repo"}} + + {{else}} + {{ctx.Locale.Tr "repo.editor.fork_not_editable"}} + {{.NotEditableMessage}} + {{end}} + + + + +{{template "base/footer" .}} diff --git a/templates/repo/editor/patch.tmpl b/templates/repo/editor/patch.tmpl index 33a7c2a89d8c7..58160e30e5aa9 100644 --- a/templates/repo/editor/patch.tmpl +++ b/templates/repo/editor/patch.tmpl @@ -10,6 +10,11 @@ {{.CsrfTokenHtml}} + {{if .ForkRepo}} + + {{ctx.Locale.Tr "repo.editor.fork_edit_description" .ForkRepo.FullName}} + + {{end}} {{ctx.Locale.Tr "repo.editor.patching"}} diff --git a/templates/repo/editor/upload.tmpl b/templates/repo/editor/upload.tmpl index 572502040678a..2b3737eeac32d 100644 --- a/templates/repo/editor/upload.tmpl +++ b/templates/repo/editor/upload.tmpl @@ -5,6 +5,11 @@ {{template "base/alert" .}} {{.CsrfTokenHtml}} + {{if .ForkRepo}} + + {{ctx.Locale.Tr "repo.editor.fork_edit_description" .ForkRepo.FullName}} + + {{end}} {{.Repository.Name}} diff --git a/templates/repo/empty.tmpl b/templates/repo/empty.tmpl index ae3f95045bc58..c1975a73a624b 100644 --- a/templates/repo/empty.tmpl +++ b/templates/repo/empty.tmpl @@ -26,7 +26,7 @@ {{ctx.Locale.Tr "repo.clone_this_repo"}} {{ctx.Locale.Tr "repo.clone_helper" "http://git-scm.com/book/en/v2/Git-Basics-Getting-a-Git-Repository"}} - {{if and .CanWriteCode (not .Repository.IsArchived)}} + {{if and .CanWriteCode .Repository.CanEnableEditor}} {{ctx.Locale.Tr "repo.editor.new_file"}} diff --git a/templates/repo/view_content.tmpl b/templates/repo/view_content.tmpl index 292a2f878c800..1b6a02a1dc241 100644 --- a/templates/repo/view_content.tmpl +++ b/templates/repo/view_content.tmpl @@ -41,8 +41,9 @@ {{ctx.Locale.Tr "repo.find_file.go_to_file"}} {{end}} - {{if and .CanWriteCode .RefFullName.IsBranch (not .Repository.IsMirror) (not .Repository.IsArchived) (not .IsViewFile)}} - + {{if and .RefFullName.IsBranch (not .IsViewFile) .Repository.CanEnableEditor}} + {{ctx.Locale.Tr "repo.editor.add_file"}} {{svg "octicon-triangle-down" 14 "dropdown icon"}} diff --git a/templates/repo/view_file.tmpl b/templates/repo/view_file.tmpl index f01adccadc5de..2d891e5b164a8 100644 --- a/templates/repo/view_file.tmpl +++ b/templates/repo/view_file.tmpl @@ -77,8 +77,12 @@ {{ctx.Locale.Tr "repo.unescape_control_characters"}} {{ctx.Locale.Tr "repo.escape_control_characters"}} {{end}} - {{if and .ReadmeInList .CanEditReadmeFile}} - {{svg "octicon-pencil"}} + {{if and .ReadmeInList .Repository.CanEnableEditor}} + {{if .CanEditReadmeFile}} + {{svg "octicon-pencil"}} + {{else}} + {{svg "octicon-pencil"}} + {{end}} {{end}} diff --git a/tests/integration/editor_test.go b/tests/integration/editor_test.go index a5936d86de225..3c54228a134de 100644 --- a/tests/integration/editor_test.go +++ b/tests/integration/editor_test.go @@ -20,6 +20,7 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/test" "code.gitea.io/gitea/modules/translation" "code.gitea.io/gitea/tests" @@ -30,13 +31,26 @@ import ( func TestCreateFile(t *testing.T) { onGiteaRun(t, func(t *testing.T, u *url.URL) { session := loginUser(t, "user2") - testCreateFile(t, session, "user2", "repo1", "master", "test.txt", "Content") + testCreateFile(t, session, "user2", "user2", "repo1", "master", "master", "direct", "test.txt", "Content", "") + testCreateFile( + t, session, "user2", "user2", "repo1", "master", "master", "direct", "test.txt", "Content", + `A file named "test.txt" already exists in this repository.`) + testCreateFile(t, session, "user2", "user2", "repo1", "master", "master", "commit-to-new-branch", "test2.txt", "Content", + `Branch "master" already exists in this repository.`) }) } -func testCreateFile(t *testing.T, session *TestSession, user, repo, branch, filePath, content string) *httptest.ResponseRecorder { +func TestCreateFileFork(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + session := loginUser(t, "user4") + forkToEdit(t, session, "user2", "repo1", "_new", "master", "test.txt") + testCreateFile(t, session, "user4", "user2", "repo1", "master", "feature/test", "commit-to-new-branch", "test.txt", "Content", "") + }) +} + +func testCreateFile(t *testing.T, session *TestSession, user, owner, repo, branch, targetBranch, commitChoice, filePath, content, expectedError string) { // Request editor page - newURL := fmt.Sprintf("/%s/%s/_new/%s/", user, repo, branch) + newURL := fmt.Sprintf("/%s/%s/_new/%s/", owner, repo, branch) req := NewRequest(t, "GET", newURL) resp := session.MakeRequest(t, req, http.StatusOK) @@ -46,70 +60,97 @@ func testCreateFile(t *testing.T, session *TestSession, user, repo, branch, file // Save new file to master branch req = NewRequestWithValues(t, "POST", newURL, map[string]string{ - "_csrf": doc.GetCSRF(), - "last_commit": lastCommit, - "tree_path": filePath, - "content": content, - "commit_choice": "direct", + "_csrf": doc.GetCSRF(), + "last_commit": lastCommit, + "tree_path": filePath, + "content": content, + "commit_choice": commitChoice, + "new_branch_name": targetBranch, }) - return session.MakeRequest(t, req, http.StatusSeeOther) + + if expectedError != "" { + resp := session.MakeRequest(t, req, http.StatusOK) + + // Check for expextecd error message + htmlDoc := NewHTMLParser(t, resp.Body) + assert.Contains(t, htmlDoc.doc.Find(".ui.flash-message").Text(), expectedError) + return + } + + session.MakeRequest(t, req, http.StatusSeeOther) + + // Check new file exists + req = NewRequestf(t, "GET", "/%s/%s/src/branch/%s/%s", user, repo, targetBranch, filePath) + session.MakeRequest(t, req, http.StatusOK) } func TestCreateFileOnProtectedBranch(t *testing.T) { onGiteaRun(t, func(t *testing.T, u *url.URL) { session := loginUser(t, "user2") + testCreateFileOnProtectedBranch(t, session, "user2", "user2", "repo1", "master", "master", "direct") + }) +} - csrf := GetUserCSRFToken(t, session) - // Change master branch to protected - req := NewRequestWithValues(t, "POST", "/user2/repo1/settings/branches/edit", map[string]string{ - "_csrf": csrf, - "rule_name": "master", - "enable_push": "true", - }) - session.MakeRequest(t, req, http.StatusSeeOther) - // Check if master branch has been locked successfully - flashMsg := session.GetCookieFlashMessage() - assert.Equal(t, `Branch protection for rule "master" has been updated.`, flashMsg.SuccessMsg) +func TestCreateFileOnProtectedBranchFork(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + session := loginUser(t, "user4") + forkToEdit(t, session, "user2", "repo1", "_new", "master", "test.txt") + testCreateFileOnProtectedBranch(t, session, "user4", "user2", "repo1", "master", "feature/test", "commit-to-new-branch") + }) +} - // Request editor page - req = NewRequest(t, "GET", "/user2/repo1/_new/master/") - resp := session.MakeRequest(t, req, http.StatusOK) +func testCreateFileOnProtectedBranch(t *testing.T, session *TestSession, user, owner, repo, branch, targetBranch, commitChoice string) { + csrf := GetUserCSRFToken(t, session) + // Change target branch to protected + req := NewRequestWithValues(t, "POST", path.Join(user, repo, "settings", "branches", "edit"), map[string]string{ + "_csrf": csrf, + "rule_name": targetBranch, + "enable_push": "true", + }) + session.MakeRequest(t, req, http.StatusSeeOther) + // Check if target branch has been locked successfully + flashMsg := session.GetCookieFlashMessage() + assert.Equal(t, fmt.Sprintf(`Branch protection for rule "%s" has been updated.`, targetBranch), flashMsg.SuccessMsg) - doc := NewHTMLParser(t, resp.Body) - lastCommit := doc.GetInputValueByName("last_commit") - assert.NotEmpty(t, lastCommit) + // Request editor page + req = NewRequest(t, "GET", path.Join(owner, repo, "_new", branch)) + resp := session.MakeRequest(t, req, http.StatusOK) - // Save new file to master branch - req = NewRequestWithValues(t, "POST", "/user2/repo1/_new/master/", map[string]string{ - "_csrf": doc.GetCSRF(), - "last_commit": lastCommit, - "tree_path": "test.txt", - "content": "Content", - "commit_choice": "direct", - }) + doc := NewHTMLParser(t, resp.Body) + lastCommit := doc.GetInputValueByName("last_commit") + assert.NotEmpty(t, lastCommit) - resp = session.MakeRequest(t, req, http.StatusOK) - // Check body for error message - assert.Contains(t, resp.Body.String(), "Cannot commit to protected branch "master".") + // Save new file to target branch + req = NewRequestWithValues(t, "POST", path.Join(owner, repo, "_new", branch), map[string]string{ + "_csrf": doc.GetCSRF(), + "last_commit": lastCommit, + "tree_path": "test.txt", + "content": "Content", + "commit_choice": commitChoice, + "new_branch_name": targetBranch, + }) - // remove the protected branch - csrf = GetUserCSRFToken(t, session) + resp = session.MakeRequest(t, req, http.StatusOK) + // Check body for error message + assert.Contains(t, resp.Body.String(), fmt.Sprintf("Cannot commit to protected branch "%s".", targetBranch)) - // Change master branch to protected - req = NewRequestWithValues(t, "POST", "/user2/repo1/settings/branches/1/delete", map[string]string{ - "_csrf": csrf, - }) + // remove the protected branch + csrf = GetUserCSRFToken(t, session) + + // Change target branch to protected + req = NewRequestWithValues(t, "POST", path.Join(user, repo, "settings", "branches", "1", "delete"), map[string]string{ + "_csrf": csrf, + }) - resp = session.MakeRequest(t, req, http.StatusOK) + resp = session.MakeRequest(t, req, http.StatusOK) - res := make(map[string]string) - assert.NoError(t, json.NewDecoder(resp.Body).Decode(&res)) - assert.Equal(t, "/user2/repo1/settings/branches", res["redirect"]) + res := make(map[string]string) + assert.NoError(t, json.NewDecoder(resp.Body).Decode(&res)) + assert.Equal(t, "/"+path.Join(user, repo, "settings", "branches"), res["redirect"]) - // Check if master branch has been locked successfully - flashMsg = session.GetCookieFlashMessage() - assert.Equal(t, `Removing branch protection rule "1" failed.`, flashMsg.ErrorMsg) - }) + // Check if target branch has been locked successfully + flashMsg = session.GetCookieFlashMessage() + assert.Equal(t, `Removing branch protection rule "1" failed.`, flashMsg.ErrorMsg) } func testEditFile(t *testing.T, session *TestSession, user, repo, branch, filePath, newContent string) *httptest.ResponseRecorder { @@ -141,9 +182,9 @@ func testEditFile(t *testing.T, session *TestSession, user, repo, branch, filePa return resp } -func testEditFileToNewBranch(t *testing.T, session *TestSession, user, repo, branch, targetBranch, filePath, newContent string) *httptest.ResponseRecorder { +func testEditFileToNewBranch(t *testing.T, session *TestSession, user, owner, repo, branch, targetBranch, filePath, newContent string) *httptest.ResponseRecorder { // Get to the 'edit this file' page - req := NewRequest(t, "GET", path.Join(user, repo, "_edit", branch, filePath)) + req := NewRequest(t, "GET", path.Join(owner, repo, "_edit", branch, filePath)) resp := session.MakeRequest(t, req, http.StatusOK) htmlDoc := NewHTMLParser(t, resp.Body) @@ -151,7 +192,7 @@ func testEditFileToNewBranch(t *testing.T, session *TestSession, user, repo, bra assert.NotEmpty(t, lastCommit) // Submit the edits - req = NewRequestWithValues(t, "POST", path.Join(user, repo, "_edit", branch, filePath), + req = NewRequestWithValues(t, "POST", path.Join(owner, repo, "_edit", branch, filePath), map[string]string{ "_csrf": htmlDoc.GetCSRF(), "last_commit": lastCommit, @@ -181,10 +222,168 @@ func TestEditFile(t *testing.T) { func TestEditFileToNewBranch(t *testing.T) { onGiteaRun(t, func(t *testing.T, u *url.URL) { session := loginUser(t, "user2") - testEditFileToNewBranch(t, session, "user2", "repo1", "master", "feature/test", "README.md", "Hello, World (Edited)\n") + testEditFileToNewBranch(t, session, "user2", "user2", "repo1", "master", "feature/test", "README.md", "Hello, World (Edited)\n") }) } +func TestEditFileToNewBranchFork(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + session := loginUser(t, "user4") + forkToEdit(t, session, "user2", "repo1", "_edit", "master", "README.md") + testEditFileToNewBranch(t, session, "user4", "user2", "repo1", "master", "feature/test", "README.md", "Hello, World (Edited)\n") + }) +} + +func testEditFileDiffPreview(t *testing.T, session *TestSession, user, repo, branch, filePath string) { + // Get to the 'edit this file' page + req := NewRequest(t, "GET", path.Join(user, repo, "_edit", branch, filePath)) + resp := session.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + lastCommit := htmlDoc.GetInputValueByName("last_commit") + assert.NotEmpty(t, lastCommit) + + // Preview the changes + req = NewRequestWithValues(t, "POST", path.Join(user, repo, "_preview", branch, filePath), + map[string]string{ + "_csrf": htmlDoc.GetCSRF(), + "content": "Hello, World (Edited)\n", + }, + ) + resp = session.MakeRequest(t, req, http.StatusOK) + + assert.Contains(t, resp.Body.String(), `Hello, World (Edited)`) +} + +func TestEditFileDiffPreview(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + session := loginUser(t, "user2") + testEditFileDiffPreview(t, session, "user2", "repo1", "master", "README.md") + }) +} + +func TestDeleteFile(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + session := loginUser(t, "user2") + testDeleteFile(t, session, "user2", "user2", "repo1", "master", "master", "direct", "README.md", "") + testDeleteFile(t, session, "user2", "user2", "repo1", "master", "master", "direct", "MISSING.md", + `The file being deleted, "MISSING.md", no longer exists in this repository.`) + }) +} + +func TestDeleteFileFork(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + session := loginUser(t, "user4") + forkToEdit(t, session, "user2", "repo1", "_delete", "master", "README.md") + testDeleteFile(t, session, "user4", "user2", "repo1", "master", "feature/test", "commit-to-new-branch", "README.md", "") + testDeleteFile(t, session, "user4", "user2", "repo1", "master", "feature/missing", "commit-to-new-branch", "MISSING.md", + `The file being deleted, "MISSING.md", no longer exists in this repository.`) + }) +} + +func testDeleteFile(t *testing.T, session *TestSession, user, owner, repo, branch, targetBranch, commitChoice, filePath, expectedError string) { + if expectedError == "" { + // Check file exists + req := NewRequestf(t, "GET", "/%s/%s/src/branch/%s/%s", owner, repo, branch, filePath) + session.MakeRequest(t, req, http.StatusOK) + } + + // Request editor page + newURL := fmt.Sprintf("/%s/%s/_delete/%s/%s", owner, repo, branch, filePath) + req := NewRequest(t, "GET", newURL) + resp := session.MakeRequest(t, req, http.StatusOK) + + doc := NewHTMLParser(t, resp.Body) + lastCommit := doc.GetInputValueByName("last_commit") + assert.NotEmpty(t, lastCommit) + + // Save deleted file to target branch + req = NewRequestWithValues(t, "POST", newURL, map[string]string{ + "_csrf": doc.GetCSRF(), + "last_commit": lastCommit, + "tree_path": filePath, + "commit_choice": commitChoice, + "new_branch_name": targetBranch, + }) + + if expectedError != "" { + resp := session.MakeRequest(t, req, http.StatusOK) + + // Check for expextecd error message + htmlDoc := NewHTMLParser(t, resp.Body) + assert.Contains(t, htmlDoc.doc.Find(".ui.flash-message").Text(), expectedError) + return + } + + session.MakeRequest(t, req, http.StatusSeeOther) + + // Check file was deleted + req = NewRequestf(t, "GET", "/%s/%s/src/branch/%s/%s", user, repo, targetBranch, filePath) + session.MakeRequest(t, req, http.StatusNotFound) +} + +func TestPatchFile(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + session := loginUser(t, "user2") + testPatchFile(t, session, "user2", "user2", "repo1", "master", "feature/test", "Contents", "") + testPatchFile(t, session, "user2", "user2", "repo1", "master", "feature/test", "Contents", + `Branch "feature/test" already exists in this repository.`) + testPatchFile(t, session, "user2", "user2", "repo1", "feature/test", "feature/again", "Conflict", + `Unable to apply patch`) + }) +} + +func TestPatchFileFork(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + session := loginUser(t, "user4") + forkToEdit(t, session, "user2", "repo1", "_diffpatch", "master", "README.md") + testPatchFile(t, session, "user4", "user2", "repo1", "master", "feature/test", "Contents", "") + }) +} + +func testPatchFile(t *testing.T, session *TestSession, user, owner, repo, branch, targetBranch, contents, expectedError string) { + // Request editor page + newURL := fmt.Sprintf("/%s/%s/_diffpatch/%s/", owner, repo, branch) + req := NewRequest(t, "GET", newURL) + resp := session.MakeRequest(t, req, http.StatusOK) + + doc := NewHTMLParser(t, resp.Body) + lastCommit := doc.GetInputValueByName("last_commit") + assert.NotEmpty(t, lastCommit) + + // Save new file to master branch + req = NewRequestWithValues(t, "POST", newURL, map[string]string{ + "_csrf": doc.GetCSRF(), + "last_commit": lastCommit, + "tree_path": "__dummy__", + "content": fmt.Sprintf(`diff --git a/patch-file-1.txt b/patch-file-1.txt +new file mode 100644 +index 0000000000..aaaaaaaaaa +--- /dev/null ++++ b/patch-file-1.txt +@@ -0,0 +1 @@ ++%s +`, contents), + "commit_choice": "commit-to-new-branch", + "new_branch_name": targetBranch, + }) + + if expectedError != "" { + resp := session.MakeRequest(t, req, http.StatusOK) + + // Check for expextecd error message + htmlDoc := NewHTMLParser(t, resp.Body) + assert.Contains(t, htmlDoc.doc.Find(".ui.flash-message").Text(), expectedError) + return + } + + session.MakeRequest(t, req, http.StatusSeeOther) + + // Check new file exists + req = NewRequestf(t, "GET", "/%s/%s/src/branch/%s/%s", user, repo, targetBranch, "patch-file-1.txt") + session.MakeRequest(t, req, http.StatusOK) +} + func TestWebGitCommitEmail(t *testing.T) { onGiteaRun(t, func(t *testing.T, _ *url.URL) { user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) @@ -337,3 +536,95 @@ index 0000000000..bbbbbbbbbb }) }) } + +func forkToEdit(t *testing.T, session *TestSession, owner, repo, operation, branch, filePath string) { + // Attempt to edit file + req := NewRequest(t, "GET", path.Join(owner, repo, operation, branch, filePath)) + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + // Fork + req = NewRequestWithValues(t, "POST", path.Join(owner, repo, "_fork_to_edit", branch), + map[string]string{ + "_csrf": htmlDoc.GetCSRF(), + "tree_path": filePath, + "edit_operation": operation, + }, + ) + resp = session.MakeRequest(t, req, http.StatusSeeOther) + assert.Equal(t, "/"+path.Join(owner, repo, operation, branch, filePath), test.RedirectURL(resp)) +} + +func testForkToEditFile(t *testing.T, session *TestSession, user, owner, repo, branch, filePath string) { + // Fork repository because we can't edit it + forkToEdit(t, session, owner, repo, "_edit", branch, filePath) + + // Check the existence of the forked repo + req := NewRequestf(t, "GET", "/%s/%s/settings", user, repo) + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + // Archive the repository + req = NewRequestWithValues(t, "POST", path.Join(user, repo, "settings"), + map[string]string{ + "_csrf": htmlDoc.GetCSRF(), + "repo_name": repo, + "action": "archive", + }, + ) + session.MakeRequest(t, req, http.StatusSeeOther) + + // Check editing archived repository is disabled + req = NewRequest(t, "GET", path.Join(owner, repo, "_edit", branch, filePath)) + resp = session.MakeRequest(t, req, http.StatusOK) + assert.Contains(t, resp.Body.String(), "Fork Repository Not Editable") + + // Unfork the repository + req = NewRequestWithValues(t, "POST", path.Join(user, repo, "settings"), + map[string]string{ + "_csrf": htmlDoc.GetCSRF(), + "repo_name": repo, + "action": "convert_fork", + }, + ) + session.MakeRequest(t, req, http.StatusSeeOther) + + // Fork repository again + forkToEdit(t, session, owner, repo, "_edit", branch, filePath) + + // Check the existence of the forked repo with unique name + req = NewRequestf(t, "GET", "/%s/%s-1", user, repo) + session.MakeRequest(t, req, http.StatusOK) +} + +func TestForkToEditFile(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + session := loginUser(t, "user4") + testForkToEditFile(t, session, "user4", "user2", "repo1", "master", "README.md") + }) +} + +func TestEditFileNotAllowed(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + session := loginUser(t, "user4") + + operations := []string{"_new", "_edit", "_delete", "_upload", "_diffpatch", "_cherrypick"} + + for _, operation := range operations { + // Branch does not exist + url := path.Join("user2", "repo1", operation, "missing", "README.md") + req := NewRequest(t, "GET", url) + session.MakeRequest(t, req, http.StatusNotFound) + + // Private repository + url = path.Join("user2", "repo2", operation, "master", "Home.md") + req = NewRequest(t, "GET", url) + session.MakeRequest(t, req, http.StatusNotFound) + + // Empty repository + url = path.Join("org41", "repo61", operation, "master", "README.md") + req = NewRequest(t, "GET", url) + session.MakeRequest(t, req, http.StatusNotFound) + } + }) +} diff --git a/tests/integration/pull_compare_test.go b/tests/integration/pull_compare_test.go index 86bdd1b9e351c..c29d2001ad7f0 100644 --- a/tests/integration/pull_compare_test.go +++ b/tests/integration/pull_compare_test.go @@ -104,7 +104,7 @@ func TestPullCompare_EnableAllowEditsFromMaintainer(t *testing.T) { assert.True(t, forkedRepo.IsPrivate) // user4 creates a new branch and a PR - testEditFileToNewBranch(t, user4Session, "user4", forkedRepoName, "master", "user4/update-readme", "README.md", "Hello, World\n(Edited by user4)\n") + testEditFileToNewBranch(t, user4Session, "user4", "user4", forkedRepoName, "master", "user4/update-readme", "README.md", "Hello, World\n(Edited by user4)\n") resp := testPullCreateDirectly(t, user4Session, repo3.OwnerName, repo3.Name, "master", "user4", forkedRepoName, "user4/update-readme", "PR for user4 forked repo3") prURL := test.RedirectURL(resp) diff --git a/tests/integration/pull_merge_test.go b/tests/integration/pull_merge_test.go index cf50d5e639e2d..3080f484ce2da 100644 --- a/tests/integration/pull_merge_test.go +++ b/tests/integration/pull_merge_test.go @@ -180,7 +180,7 @@ func TestPullCleanUpAfterMerge(t *testing.T) { onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { session := loginUser(t, "user1") testRepoFork(t, session, "user2", "repo1", "user1", "repo1", "") - testEditFileToNewBranch(t, session, "user1", "repo1", "master", "feature/test", "README.md", "Hello, World (Edited - TestPullCleanUpAfterMerge)\n") + testEditFileToNewBranch(t, session, "user1", "user1", "repo1", "master", "feature/test", "README.md", "Hello, World (Edited - TestPullCleanUpAfterMerge)\n") resp := testPullCreate(t, session, "user1", "repo1", false, "master", "feature/test", "This is a pull title") @@ -234,8 +234,8 @@ func TestCantMergeConflict(t *testing.T) { onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { session := loginUser(t, "user1") testRepoFork(t, session, "user2", "repo1", "user1", "repo1", "") - testEditFileToNewBranch(t, session, "user1", "repo1", "master", "conflict", "README.md", "Hello, World (Edited Once)\n") - testEditFileToNewBranch(t, session, "user1", "repo1", "master", "base", "README.md", "Hello, World (Edited Twice)\n") + testEditFileToNewBranch(t, session, "user1", "user1", "repo1", "master", "conflict", "README.md", "Hello, World (Edited Once)\n") + testEditFileToNewBranch(t, session, "user1", "user1", "repo1", "master", "base", "README.md", "Hello, World (Edited Twice)\n") // Use API to create a conflicting pr token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) @@ -280,7 +280,7 @@ func TestCantMergeUnrelated(t *testing.T) { onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { session := loginUser(t, "user1") testRepoFork(t, session, "user2", "repo1", "user1", "repo1", "") - testEditFileToNewBranch(t, session, "user1", "repo1", "master", "base", "README.md", "Hello, World (Edited Twice)\n") + testEditFileToNewBranch(t, session, "user1", "user1", "repo1", "master", "base", "README.md", "Hello, World (Edited Twice)\n") // Now we want to create a commit on a branch that is totally unrelated to our current head // Drop down to pure code at this point @@ -343,7 +343,7 @@ func TestCantMergeUnrelated(t *testing.T) { _, _, err = git.NewCommand("branch", "unrelated").AddDynamicArguments(commitSha).RunStdString(git.DefaultContext, &git.RunOpts{Dir: path}) assert.NoError(t, err) - testEditFileToNewBranch(t, session, "user1", "repo1", "master", "conflict", "README.md", "Hello, World (Edited Once)\n") + testEditFileToNewBranch(t, session, "user1", "user1", "repo1", "master", "conflict", "README.md", "Hello, World (Edited Once)\n") // Use API to create a conflicting pr token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) @@ -375,7 +375,7 @@ func TestFastForwardOnlyMerge(t *testing.T) { onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { session := loginUser(t, "user1") testRepoFork(t, session, "user2", "repo1", "user1", "repo1", "") - testEditFileToNewBranch(t, session, "user1", "repo1", "master", "update", "README.md", "Hello, World 2\n") + testEditFileToNewBranch(t, session, "user1", "user1", "repo1", "master", "update", "README.md", "Hello, World 2\n") // Use API to create a pr from update to master token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) @@ -416,7 +416,7 @@ func TestCantFastForwardOnlyMergeDiverging(t *testing.T) { onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { session := loginUser(t, "user1") testRepoFork(t, session, "user2", "repo1", "user1", "repo1", "") - testEditFileToNewBranch(t, session, "user1", "repo1", "master", "diverging", "README.md", "Hello, World diverged\n") + testEditFileToNewBranch(t, session, "user1", "user1", "repo1", "master", "diverging", "README.md", "Hello, World diverged\n") testEditFile(t, session, "user1", "repo1", "master", "README.md", "Hello, World 2\n") // Use API to create a pr from diverging to update @@ -538,9 +538,9 @@ func TestConflictChecking(t *testing.T) { func TestPullRetargetChildOnBranchDelete(t *testing.T) { onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { session := loginUser(t, "user1") - testEditFileToNewBranch(t, session, "user2", "repo1", "master", "base-pr", "README.md", "Hello, World\n(Edited - TestPullRetargetOnCleanup - base PR)\n") + testEditFileToNewBranch(t, session, "user2", "user2", "repo1", "master", "base-pr", "README.md", "Hello, World\n(Edited - TestPullRetargetOnCleanup - base PR)\n") testRepoFork(t, session, "user2", "repo1", "user1", "repo1", "") - testEditFileToNewBranch(t, session, "user1", "repo1", "base-pr", "child-pr", "README.md", "Hello, World\n(Edited - TestPullRetargetOnCleanup - base PR)\n(Edited - TestPullRetargetOnCleanup - child PR)") + testEditFileToNewBranch(t, session, "user1", "user1", "repo1", "base-pr", "child-pr", "README.md", "Hello, World\n(Edited - TestPullRetargetOnCleanup - base PR)\n(Edited - TestPullRetargetOnCleanup - child PR)") respBasePR := testPullCreate(t, session, "user2", "repo1", true, "master", "base-pr", "Base Pull Request") elemBasePR := strings.Split(test.RedirectURL(respBasePR), "/") @@ -573,8 +573,8 @@ func TestPullDontRetargetChildOnWrongRepo(t *testing.T) { onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { session := loginUser(t, "user1") testRepoFork(t, session, "user2", "repo1", "user1", "repo1", "") - testEditFileToNewBranch(t, session, "user1", "repo1", "master", "base-pr", "README.md", "Hello, World\n(Edited - TestPullDontRetargetChildOnWrongRepo - base PR)\n") - testEditFileToNewBranch(t, session, "user1", "repo1", "base-pr", "child-pr", "README.md", "Hello, World\n(Edited - TestPullDontRetargetChildOnWrongRepo - base PR)\n(Edited - TestPullDontRetargetChildOnWrongRepo - child PR)") + testEditFileToNewBranch(t, session, "user1", "user1", "repo1", "master", "base-pr", "README.md", "Hello, World\n(Edited - TestPullDontRetargetChildOnWrongRepo - base PR)\n") + testEditFileToNewBranch(t, session, "user1", "user1", "repo1", "base-pr", "child-pr", "README.md", "Hello, World\n(Edited - TestPullDontRetargetChildOnWrongRepo - base PR)\n(Edited - TestPullDontRetargetChildOnWrongRepo - child PR)") respBasePR := testPullCreate(t, session, "user1", "repo1", false, "master", "base-pr", "Base Pull Request") elemBasePR := strings.Split(test.RedirectURL(respBasePR), "/") @@ -610,7 +610,7 @@ func TestPullRequestMergedWithNoPermissionDeleteBranch(t *testing.T) { onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { session := loginUser(t, "user4") testRepoFork(t, session, "user2", "repo1", "user4", "repo1", "") - testEditFileToNewBranch(t, session, "user4", "repo1", "master", "base-pr", "README.md", "Hello, World\n(Edited - TestPullDontRetargetChildOnWrongRepo - base PR)\n") + testEditFileToNewBranch(t, session, "user4", "user4", "repo1", "master", "base-pr", "README.md", "Hello, World\n(Edited - TestPullDontRetargetChildOnWrongRepo - base PR)\n") respBasePR := testPullCreate(t, session, "user4", "repo1", false, "master", "base-pr", "Base Pull Request") elemBasePR := strings.Split(test.RedirectURL(respBasePR), "/") diff --git a/tests/integration/pull_review_test.go b/tests/integration/pull_review_test.go index 13b2384e9c95b..2fd1b407b61b3 100644 --- a/tests/integration/pull_review_test.go +++ b/tests/integration/pull_review_test.go @@ -246,7 +246,7 @@ func TestPullView_GivenApproveOrRejectReviewOnClosedPR(t *testing.T) { t.Run("Submit approve/reject review on closed PR", func(t *testing.T) { // Created a closed PR (made by user1) in the upstream repo1. - testEditFileToNewBranch(t, user1Session, "user1", "repo1", "master", "a-test-branch", "README.md", "Hello, World (Editied...again)\n") + testEditFileToNewBranch(t, user1Session, "user1", "user1", "repo1", "master", "a-test-branch", "README.md", "Hello, World (Editied...again)\n") resp := testPullCreate(t, user1Session, "user1", "repo1", false, "master", "a-test-branch", "This is a pull title") elem := strings.Split(test.RedirectURL(resp), "/") assert.Equal(t, "pulls", elem[3]) diff --git a/tests/integration/pull_status_test.go b/tests/integration/pull_status_test.go index 63ffe9432035f..d4d631da57a18 100644 --- a/tests/integration/pull_status_test.go +++ b/tests/integration/pull_status_test.go @@ -24,7 +24,7 @@ func TestPullCreate_CommitStatus(t *testing.T) { onGiteaRun(t, func(t *testing.T, u *url.URL) { session := loginUser(t, "user1") testRepoFork(t, session, "user2", "repo1", "user1", "repo1", "") - testEditFileToNewBranch(t, session, "user1", "repo1", "master", "status1", "README.md", "status1") + testEditFileToNewBranch(t, session, "user1", "user1", "repo1", "master", "status1", "README.md", "status1") url := path.Join("user1", "repo1", "compare", "master...status1") req := NewRequestWithValues(t, "POST", url, @@ -123,8 +123,8 @@ func TestPullCreate_EmptyChangesWithDifferentCommits(t *testing.T) { onGiteaRun(t, func(t *testing.T, u *url.URL) { session := loginUser(t, "user1") testRepoFork(t, session, "user2", "repo1", "user1", "repo1", "") - testEditFileToNewBranch(t, session, "user1", "repo1", "master", "status1", "README.md", "status1") - testEditFileToNewBranch(t, session, "user1", "repo1", "status1", "status1", "README.md", "# repo1\n\nDescription for repo1") + testEditFileToNewBranch(t, session, "user1", "user1", "repo1", "master", "status1", "README.md", "status1") + testEditFile(t, session, "user1", "repo1", "status1", "README.md", "# repo1\n\nDescription for repo1") url := path.Join("user1", "repo1", "compare", "master...status1") req := NewRequestWithValues(t, "POST", url, diff --git a/tests/integration/repo_activity_test.go b/tests/integration/repo_activity_test.go index d5025decba078..85110e064c32c 100644 --- a/tests/integration/repo_activity_test.go +++ b/tests/integration/repo_activity_test.go @@ -29,10 +29,10 @@ func TestRepoActivity(t *testing.T) { assert.Equal(t, "pulls", elem[3]) testPullMerge(t, session, elem[1], elem[2], elem[4], repo_model.MergeStyleMerge, false) - testEditFileToNewBranch(t, session, "user1", "repo1", "master", "feat/better_readme", "README.md", "Hello, World (Edited Again)\n") + testEditFileToNewBranch(t, session, "user1", "user1", "repo1", "master", "feat/better_readme", "README.md", "Hello, World (Edited Again)\n") testPullCreate(t, session, "user1", "repo1", false, "master", "feat/better_readme", "This is a pull title") - testEditFileToNewBranch(t, session, "user1", "repo1", "master", "feat/much_better_readme", "README.md", "Hello, World (Edited More)\n") + testEditFileToNewBranch(t, session, "user1", "user1", "repo1", "master", "feat/much_better_readme", "README.md", "Hello, World (Edited More)\n") testPullCreate(t, session, "user1", "repo1", false, "master", "feat/much_better_readme", "This is a pull title") // Create issues (3 new issues) diff --git a/tests/integration/repo_webhook_test.go b/tests/integration/repo_webhook_test.go index 89df15b8de8c3..8395c0622c73d 100644 --- a/tests/integration/repo_webhook_test.go +++ b/tests/integration/repo_webhook_test.go @@ -310,7 +310,7 @@ func Test_WebhookPush(t *testing.T) { testAPICreateWebhookForRepo(t, session, "user2", "repo1", provider.URL(), "push") // 2. trigger the webhook - testCreateFile(t, session, "user2", "repo1", "master", "test_webhook_push.md", "# a test file for webhook push") + testCreateFile(t, session, "user2", "user2", "repo1", "master", "master", "direct", "test_webhook_push.md", "# a test file for webhook push", "") // 3. validate the webhook is triggered assert.Equal(t, "push", triggeredEvent) @@ -602,7 +602,7 @@ func Test_WebhookStatus_NoWrongTrigger(t *testing.T) { testCreateWebhookForRepo(t, session, "gitea", "user2", "repo1", provider.URL(), "push_only") // 2. trigger the webhook with a push action - testCreateFile(t, session, "user2", "repo1", "master", "test_webhook_push.md", "# a test file for webhook push") + testCreateFile(t, session, "user2", "user2", "repo1", "master", "master", "direct", "test_webhook_push.md", "# a test file for webhook push", "") // 3. validate the webhook is triggered with right event assert.Equal(t, "push", trigger)
{{ctx.Locale.Tr "repo.editor.fork_edit_description" .ForkRepo.FullName}}
{{ctx.Locale.Tr "repo.editor.fork_create_description"}}
{{.NotEditableMessage}}