diff options
26 files changed, 740 insertions, 419 deletions
diff --git a/models/repo/repo.go b/models/repo/repo.go index 5aae02c6d8..34d1bf55f6 100644 --- a/models/repo/repo.go +++ b/models/repo/repo.go @@ -653,7 +653,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 4d1f7e7bf4..dc50671335 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -1399,6 +1399,13 @@ editor.revert = Revert %s onto: editor.failed_to_commit = Failed to commit changes. editor.failed_to_commit_summary = Error Message: +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 <b>%s</b>, so you can create a pull request. +editor.fork_not_editable = You have forked this repository but your fork is not editable. +editor.fork_failed_to_push_branch = Failed to push branch %s to your repository. +editor.fork_branch_exists = Branch "%s" already exists in your fork, please choose a new branch name. + commits.desc = Browse source code change history. commits.commits = Commits commits.no_commits = No commits in common. "%s" and "%s" have entirely different histories. diff --git a/routers/web/repo/editor.go b/routers/web/repo/editor.go index e8ad3cceb5..ae0b74b019 100644 --- a/routers/web/repo/editor.go +++ b/routers/web/repo/editor.go @@ -16,6 +16,7 @@ import ( "code.gitea.io/gitea/modules/charset" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/httplib" + "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/templates" @@ -39,7 +40,7 @@ const ( editorCommitChoiceNewBranch string = "commit-to-new-branch" ) -func prepareEditorCommitFormOptions(ctx *context.Context, editorAction string) { +func prepareEditorCommitFormOptions(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)) @@ -47,18 +48,28 @@ func prepareEditorCommitFormOptions(ctx *context.Context, editorAction string) { redirectTo += "?" + ctx.Req.URL.RawQuery } ctx.Redirect(redirectTo) - return + return nil } - commitFormBehaviors, err := ctx.Repo.PrepareCommitFormBehaviors(ctx, ctx.Doer) + commitFormOptions, err := context.PrepareCommitFormOptions(ctx, ctx.Doer, ctx.Repo.Repository, ctx.Repo.Permission, ctx.Repo.RefFullName) if err != nil { - ctx.ServerError("PrepareCommitFormBehaviors", err) - return + ctx.ServerError("PrepareCommitFormOptions", err) + return nil + } + + if commitFormOptions.NeedFork { + ForkToEdit(ctx) + return nil + } + + if commitFormOptions.WillSubmitToFork && !commitFormOptions.TargetRepo.CanEnableEditor() { + ctx.Data["NotFoundPrompt"] = ctx.Locale.Tr("repo.editor.fork_not_editable") + ctx.NotFound(nil) } ctx.Data["BranchLink"] = ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL() ctx.Data["TreePath"] = ctx.Repo.TreePath - ctx.Data["CommitFormBehaviors"] = commitFormBehaviors + ctx.Data["CommitFormOptions"] = commitFormOptions // for online editor ctx.Data["PreviewableExtensions"] = strings.Join(markup.PreviewableExtensions(), ",") @@ -69,25 +80,27 @@ func prepareEditorCommitFormOptions(ctx *context.Context, editorAction string) { // form fields ctx.Data["commit_summary"] = "" ctx.Data["commit_message"] = "" - ctx.Data["commit_choice"] = util.Iif(commitFormBehaviors.CanCommitToBranch, editorCommitChoiceDirect, editorCommitChoiceNewBranch) - ctx.Data["new_branch_name"] = getUniquePatchBranchName(ctx, ctx.Doer.LowerName, ctx.Repo.Repository) + ctx.Data["commit_choice"] = util.Iif(commitFormOptions.CanCommitToBranch, editorCommitChoiceDirect, editorCommitChoiceNewBranch) + ctx.Data["new_branch_name"] = getUniquePatchBranchName(ctx, ctx.Doer.LowerName, commitFormOptions.TargetRepo) ctx.Data["last_commit"] = ctx.Repo.CommitID + return commitFormOptions } func prepareTreePathFieldsAndPaths(ctx *context.Context, treePath string) { // show the tree path fields in the "breadcrumb" and help users to edit the target tree path - ctx.Data["TreeNames"], ctx.Data["TreePaths"] = getParentTreeFields(treePath) + ctx.Data["TreeNames"], ctx.Data["TreePaths"] = getParentTreeFields(strings.TrimPrefix(treePath, "/")) } -type parsedEditorCommitForm[T any] struct { - form T - commonForm *forms.CommitCommonForm - CommitFormBehaviors *context.CommitFormBehaviors - TargetBranchName string - GitCommitter *files_service.IdentityOptions +type preparedEditorCommitForm[T any] struct { + form T + commonForm *forms.CommitCommonForm + CommitFormOptions *context.CommitFormOptions + OldBranchName string + NewBranchName string + GitCommitter *files_service.IdentityOptions } -func (f *parsedEditorCommitForm[T]) GetCommitMessage(defaultCommitMessage string) string { +func (f *preparedEditorCommitForm[T]) GetCommitMessage(defaultCommitMessage string) string { commitMessage := util.IfZero(strings.TrimSpace(f.commonForm.CommitSummary), defaultCommitMessage) if body := strings.TrimSpace(f.commonForm.CommitMessage); body != "" { commitMessage += "\n\n" + body @@ -95,7 +108,7 @@ func (f *parsedEditorCommitForm[T]) GetCommitMessage(defaultCommitMessage string return commitMessage } -func parseEditorCommitSubmittedForm[T forms.CommitCommonFormInterface](ctx *context.Context) *parsedEditorCommitForm[T] { +func prepareEditorCommitSubmittedForm[T forms.CommitCommonFormInterface](ctx *context.Context) *preparedEditorCommitForm[T] { form := web.GetForm(ctx).(T) if ctx.HasError() { ctx.JSONError(ctx.GetErrMsg()) @@ -105,15 +118,22 @@ func parseEditorCommitSubmittedForm[T forms.CommitCommonFormInterface](ctx *cont commonForm := form.GetCommitCommonForm() commonForm.TreePath = files_service.CleanGitTreePath(commonForm.TreePath) - commitFormBehaviors, err := ctx.Repo.PrepareCommitFormBehaviors(ctx, ctx.Doer) + commitFormOptions, err := context.PrepareCommitFormOptions(ctx, ctx.Doer, ctx.Repo.Repository, ctx.Repo.Permission, ctx.Repo.RefFullName) if err != nil { - ctx.ServerError("PrepareCommitFormBehaviors", err) + ctx.ServerError("PrepareCommitFormOptions", err) + return nil + } + if commitFormOptions.NeedFork { + // It shouldn't happen, because we should have done the checks in the "GET" request. But just in case. + ctx.JSONError(ctx.Locale.TrString("error.not_found")) return nil } // check commit behavior - targetBranchName := util.Iif(commonForm.CommitChoice == editorCommitChoiceNewBranch, commonForm.NewBranchName, ctx.Repo.BranchName) - if targetBranchName == ctx.Repo.BranchName && !commitFormBehaviors.CanCommitToBranch { + fromBaseBranch := ctx.FormString("from_base_branch") + commitToNewBranch := commonForm.CommitChoice == editorCommitChoiceNewBranch || fromBaseBranch != "" + targetBranchName := util.Iif(commitToNewBranch, commonForm.NewBranchName, ctx.Repo.BranchName) + if targetBranchName == ctx.Repo.BranchName && !commitFormOptions.CanCommitToBranch { ctx.JSONError(ctx.Tr("repo.editor.cannot_commit_to_protected_branch", targetBranchName)) return nil } @@ -125,28 +145,63 @@ func parseEditorCommitSubmittedForm[T forms.CommitCommonFormInterface](ctx *cont return nil } - return &parsedEditorCommitForm[T]{ - form: form, - commonForm: commonForm, - CommitFormBehaviors: commitFormBehaviors, - TargetBranchName: targetBranchName, - GitCommitter: gitCommitter, + if commitToNewBranch { + // if target branch exists, we should stop + targetBranchExists, err := git_model.IsBranchExist(ctx, commitFormOptions.TargetRepo.ID, targetBranchName) + if err != nil { + ctx.ServerError("IsBranchExist", err) + return nil + } else if targetBranchExists { + if fromBaseBranch != "" { + ctx.JSONError(ctx.Tr("repo.editor.fork_branch_exists", targetBranchName)) + } else { + ctx.JSONError(ctx.Tr("repo.editor.branch_already_exists", targetBranchName)) + } + return nil + } + } + + oldBranchName := ctx.Repo.BranchName + if fromBaseBranch != "" { + err = editorPushBranchToForkedRepository(ctx, ctx.Doer, ctx.Repo.Repository.BaseRepo, fromBaseBranch, commitFormOptions.TargetRepo, targetBranchName) + if err != nil { + log.Error("Unable to editorPushBranchToForkedRepository: %v", err) + ctx.JSONError(ctx.Tr("repo.editor.fork_failed_to_push_branch", targetBranchName)) + return nil + } + // we have pushed the base branch as the new branch, now we need to commit the changes directly to the new branch + oldBranchName = targetBranchName + } + + return &preparedEditorCommitForm[T]{ + form: form, + commonForm: commonForm, + CommitFormOptions: commitFormOptions, + OldBranchName: oldBranchName, + NewBranchName: targetBranchName, + GitCommitter: gitCommitter, } } // redirectForCommitChoice redirects after committing the edit to a branch -func redirectForCommitChoice[T any](ctx *context.Context, parsed *parsedEditorCommitForm[T], treePath string) { +func redirectForCommitChoice[T any](ctx *context.Context, parsed *preparedEditorCommitForm[T], treePath string) { + // when editing a file in a PR, it should return to the origin location + if returnURI := ctx.FormString("return_uri"); returnURI != "" && httplib.IsCurrentGiteaSiteURL(ctx, returnURI) { + ctx.JSONRedirect(returnURI) + return + } + if parsed.commonForm.CommitChoice == editorCommitChoiceNewBranch { // Redirect to a pull request when possible redirectToPullRequest := false - repo, baseBranch, headBranch := ctx.Repo.Repository, ctx.Repo.BranchName, parsed.TargetBranchName - if repo.UnitEnabled(ctx, unit.TypePullRequests) { - redirectToPullRequest = true - } else if parsed.CommitFormBehaviors.CanCreateBasePullRequest { + repo, baseBranch, headBranch := ctx.Repo.Repository, parsed.OldBranchName, parsed.NewBranchName + if ctx.Repo.Repository.IsFork && parsed.CommitFormOptions.CanCreateBasePullRequest { 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 { ctx.JSONRedirect(repo.Link() + "/compare/" + util.PathEscapeSegments(baseBranch) + "..." + util.PathEscapeSegments(headBranch)) @@ -154,11 +209,9 @@ func redirectForCommitChoice[T any](ctx *context.Context, parsed *parsedEditorCo } } - returnURI := ctx.FormString("return_uri") - if returnURI == "" || !httplib.IsCurrentGiteaSiteURL(ctx, returnURI) { - returnURI = util.URLJoin(ctx.Repo.RepoLink, "src/branch", util.PathEscapeSegments(parsed.TargetBranchName), util.PathEscapeSegments(treePath)) - } - ctx.JSONRedirect(returnURI) + // redirect to the newly updated file + redirectTo := util.URLJoin(ctx.Repo.RepoLink, "src/branch", util.PathEscapeSegments(parsed.NewBranchName), util.PathEscapeSegments(treePath)) + ctx.JSONRedirect(redirectTo) } func editFileOpenExisting(ctx *context.Context) (prefetch []byte, dataRc io.ReadCloser, fInfo *fileInfo) { @@ -268,7 +321,7 @@ func EditFile(ctx *context.Context) { func EditFilePost(ctx *context.Context) { editorAction := ctx.PathParam("editor_action") isNewFile := editorAction == "_new" - parsed := parseEditorCommitSubmittedForm[*forms.EditRepoFileForm](ctx) + parsed := prepareEditorCommitSubmittedForm[*forms.EditRepoFileForm](ctx) if ctx.Written() { return } @@ -292,8 +345,8 @@ func EditFilePost(ctx *context.Context) { _, err := files_service.ChangeRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, &files_service.ChangeRepoFilesOptions{ LastCommitID: parsed.form.LastCommit, - OldBranch: ctx.Repo.BranchName, - NewBranch: parsed.TargetBranchName, + OldBranch: parsed.OldBranchName, + NewBranch: parsed.NewBranchName, Message: parsed.GetCommitMessage(defaultCommitMessage), Files: []*files_service.ChangeRepoFile{ { @@ -308,7 +361,7 @@ func EditFilePost(ctx *context.Context) { Committer: parsed.GitCommitter, }) if err != nil { - editorHandleFileOperationError(ctx, parsed.TargetBranchName, err) + editorHandleFileOperationError(ctx, parsed.NewBranchName, err) return } @@ -327,7 +380,7 @@ func DeleteFile(ctx *context.Context) { // DeleteFilePost response for deleting file func DeleteFilePost(ctx *context.Context) { - parsed := parseEditorCommitSubmittedForm[*forms.DeleteRepoFileForm](ctx) + parsed := prepareEditorCommitSubmittedForm[*forms.DeleteRepoFileForm](ctx) if ctx.Written() { return } @@ -335,8 +388,8 @@ func DeleteFilePost(ctx *context.Context) { treePath := ctx.Repo.TreePath _, err := files_service.ChangeRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, &files_service.ChangeRepoFilesOptions{ LastCommitID: parsed.form.LastCommit, - OldBranch: ctx.Repo.BranchName, - NewBranch: parsed.TargetBranchName, + OldBranch: parsed.OldBranchName, + NewBranch: parsed.NewBranchName, Files: []*files_service.ChangeRepoFile{ { Operation: "delete", @@ -349,29 +402,29 @@ func DeleteFilePost(ctx *context.Context) { Committer: parsed.GitCommitter, }) if err != nil { - editorHandleFileOperationError(ctx, parsed.TargetBranchName, err) + editorHandleFileOperationError(ctx, parsed.NewBranchName, err) return } ctx.Flash.Success(ctx.Tr("repo.editor.file_delete_success", treePath)) - redirectTreePath := getClosestParentWithFiles(ctx.Repo.GitRepo, parsed.TargetBranchName, treePath) + redirectTreePath := getClosestParentWithFiles(ctx.Repo.GitRepo, parsed.NewBranchName, treePath) redirectForCommitChoice(ctx, parsed, redirectTreePath) } func UploadFile(ctx *context.Context) { ctx.Data["PageIsUpload"] = true - upload.AddUploadContext(ctx, "repo") prepareTreePathFieldsAndPaths(ctx, ctx.Repo.TreePath) - - prepareEditorCommitFormOptions(ctx, "_upload") + opts := prepareEditorCommitFormOptions(ctx, "_upload") if ctx.Written() { return } + upload.AddUploadContextForRepo(ctx, opts.TargetRepo) + ctx.HTML(http.StatusOK, tplUploadFile) } func UploadFilePost(ctx *context.Context) { - parsed := parseEditorCommitSubmittedForm[*forms.UploadRepoFileForm](ctx) + parsed := prepareEditorCommitSubmittedForm[*forms.UploadRepoFileForm](ctx) if ctx.Written() { return } @@ -379,8 +432,8 @@ func UploadFilePost(ctx *context.Context) { defaultCommitMessage := ctx.Locale.TrString("repo.editor.upload_files_to_dir", util.IfZero(parsed.form.TreePath, "/")) err := files_service.UploadRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, &files_service.UploadRepoFileOptions{ LastCommitID: parsed.form.LastCommit, - OldBranch: ctx.Repo.BranchName, - NewBranch: parsed.TargetBranchName, + OldBranch: parsed.OldBranchName, + NewBranch: parsed.NewBranchName, TreePath: parsed.form.TreePath, Message: parsed.GetCommitMessage(defaultCommitMessage), Files: parsed.form.Files, @@ -389,7 +442,7 @@ func UploadFilePost(ctx *context.Context) { Committer: parsed.GitCommitter, }) if err != nil { - editorHandleFileOperationError(ctx, parsed.TargetBranchName, err) + editorHandleFileOperationError(ctx, parsed.NewBranchName, err) return } redirectForCommitChoice(ctx, parsed, parsed.form.TreePath) diff --git a/routers/web/repo/editor_apply_patch.go b/routers/web/repo/editor_apply_patch.go index 9fd7a9468b..bd2811cc5f 100644 --- a/routers/web/repo/editor_apply_patch.go +++ b/routers/web/repo/editor_apply_patch.go @@ -25,7 +25,7 @@ func NewDiffPatch(ctx *context.Context) { // NewDiffPatchPost response for sending patch page func NewDiffPatchPost(ctx *context.Context) { - parsed := parseEditorCommitSubmittedForm[*forms.EditRepoFileForm](ctx) + parsed := prepareEditorCommitSubmittedForm[*forms.EditRepoFileForm](ctx) if ctx.Written() { return } @@ -33,8 +33,8 @@ func NewDiffPatchPost(ctx *context.Context) { defaultCommitMessage := ctx.Locale.TrString("repo.editor.patch") _, err := files.ApplyDiffPatch(ctx, ctx.Repo.Repository, ctx.Doer, &files.ApplyDiffPatchOptions{ LastCommitID: parsed.form.LastCommit, - OldBranch: ctx.Repo.BranchName, - NewBranch: parsed.TargetBranchName, + OldBranch: parsed.OldBranchName, + NewBranch: parsed.NewBranchName, Message: parsed.GetCommitMessage(defaultCommitMessage), Content: strings.ReplaceAll(parsed.form.Content.Value(), "\r\n", "\n"), Author: parsed.GitCommitter, @@ -44,7 +44,7 @@ func NewDiffPatchPost(ctx *context.Context) { err = util.ErrorWrapLocale(err, "repo.editor.fail_to_apply_patch") } if err != nil { - editorHandleFileOperationError(ctx, parsed.TargetBranchName, err) + editorHandleFileOperationError(ctx, parsed.NewBranchName, err) return } redirectForCommitChoice(ctx, parsed, parsed.form.TreePath) diff --git a/routers/web/repo/editor_cherry_pick.go b/routers/web/repo/editor_cherry_pick.go index 4c93d610cc..10c2741b1c 100644 --- a/routers/web/repo/editor_cherry_pick.go +++ b/routers/web/repo/editor_cherry_pick.go @@ -45,7 +45,7 @@ func CherryPick(ctx *context.Context) { func CherryPickPost(ctx *context.Context) { fromCommitID := ctx.PathParam("sha") - parsed := parseEditorCommitSubmittedForm[*forms.CherryPickForm](ctx) + parsed := prepareEditorCommitSubmittedForm[*forms.CherryPickForm](ctx) if ctx.Written() { return } @@ -53,8 +53,8 @@ func CherryPickPost(ctx *context.Context) { defaultCommitMessage := util.Iif(parsed.form.Revert, ctx.Locale.TrString("repo.commit.revert-header", fromCommitID), ctx.Locale.TrString("repo.commit.cherry-pick-header", fromCommitID)) opts := &files.ApplyDiffPatchOptions{ LastCommitID: parsed.form.LastCommit, - OldBranch: ctx.Repo.BranchName, - NewBranch: parsed.TargetBranchName, + OldBranch: parsed.OldBranchName, + NewBranch: parsed.NewBranchName, Message: parsed.GetCommitMessage(defaultCommitMessage), Author: parsed.GitCommitter, Committer: parsed.GitCommitter, @@ -78,7 +78,7 @@ func CherryPickPost(ctx *context.Context) { } } if err != nil { - editorHandleFileOperationError(ctx, parsed.TargetBranchName, err) + editorHandleFileOperationError(ctx, parsed.NewBranchName, err) return } } diff --git a/routers/web/repo/editor_fork.go b/routers/web/repo/editor_fork.go new file mode 100644 index 0000000000..b78a634c00 --- /dev/null +++ b/routers/web/repo/editor_fork.go @@ -0,0 +1,31 @@ +// Copyright 2025 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/services/context" + repo_service "code.gitea.io/gitea/services/repository" +) + +const tplEditorFork templates.TplName = "repo/editor/fork" + +func ForkToEdit(ctx *context.Context) { + ctx.HTML(http.StatusOK, tplEditorFork) +} + +func ForkToEditPost(ctx *context.Context) { + ForkRepoTo(ctx, ctx.Doer, repo_service.ForkRepoOptions{ + BaseRepo: ctx.Repo.Repository, + Name: getUniqueRepositoryName(ctx, ctx.Doer.ID, ctx.Repo.Repository.Name), + Description: ctx.Repo.Repository.Description, + SingleBranch: ctx.Repo.Repository.DefaultBranch, // maybe we only need the default branch in the fork? + }) + if ctx.Written() { + return + } + ctx.JSONRedirect("") // reload the page, the new fork should be editable now +} diff --git a/routers/web/repo/editor_util.go b/routers/web/repo/editor_util.go index 8744b4479e..f910f0bd40 100644 --- a/routers/web/repo/editor_util.go +++ b/routers/web/repo/editor_util.go @@ -11,9 +11,11 @@ import ( git_model "code.gitea.io/gitea/models/git" repo_model "code.gitea.io/gitea/models/repo" + 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/log" + repo_module "code.gitea.io/gitea/modules/repository" context_service "code.gitea.io/gitea/services/context" ) @@ -83,3 +85,26 @@ func getParentTreeFields(treePath string) (treeNames, treePaths []string) { } return treeNames, treePaths } + +// getUniqueRepositoryName Gets a unique repository name for a user +// It will append a -<num> postfix if the name is already taken +func getUniqueRepositoryName(ctx context.Context, ownerID int64, name string) string { + uniqueName := name + for i := 1; i < 1000; i++ { + _, err := repo_model.GetRepositoryByName(ctx, ownerID, uniqueName) + if err != nil || repo_model.IsErrRepoNotExist(err) { + return uniqueName + } + uniqueName = fmt.Sprintf("%s-%d", name, i) + i++ + } + return "" +} + +func editorPushBranchToForkedRepository(ctx context.Context, doer *user_model.User, baseRepo *repo_model.Repository, baseBranchName string, targetRepo *repo_model.Repository, targetBranchName string) error { + return git.Push(ctx, baseRepo.RepoPath(), git.PushOptions{ + Remote: targetRepo.RepoPath(), + Branch: baseBranchName + ":" + targetBranchName, + Env: repo_module.PushingEnvironment(doer, targetRepo), + }) +} diff --git a/routers/web/repo/fork.go b/routers/web/repo/fork.go index 9f5cda10c2..c2694e540f 100644 --- a/routers/web/repo/fork.go +++ b/routers/web/repo/fork.go @@ -189,17 +189,25 @@ func ForkPost(ctx *context.Context) { } } - repo, err := repo_service.ForkRepository(ctx, ctx.Doer, ctxUser, repo_service.ForkRepoOptions{ + repo := ForkRepoTo(ctx, ctxUser, repo_service.ForkRepoOptions{ BaseRepo: forkRepo, Name: form.RepoName, Description: form.Description, SingleBranch: form.ForkSingleBranch, }) + if ctx.Written() { + return + } + ctx.JSONRedirect(ctxUser.HomeLink() + "/" + url.PathEscape(repo.Name)) +} + +func ForkRepoTo(ctx *context.Context, owner *user_model.User, forkOpts repo_service.ForkRepoOptions) *repo_model.Repository { + repo, err := repo_service.ForkRepository(ctx, ctx.Doer, owner, forkOpts) if err != nil { ctx.Data["Err_RepoName"] = true switch { case repo_model.IsErrReachLimitOfRepo(err): - maxCreationLimit := ctxUser.MaxCreationLimit() + maxCreationLimit := owner.MaxCreationLimit() msg := ctx.TrN(maxCreationLimit, "repo.form.reach_limit_of_creation_1", "repo.form.reach_limit_of_creation_n", maxCreationLimit) ctx.JSONError(msg) case repo_model.IsErrRepoAlreadyExist(err): @@ -224,9 +232,7 @@ func ForkPost(ctx *context.Context) { default: ctx.ServerError("ForkPost", err) } - return + return nil } - - log.Trace("Repository forked[%d]: %s/%s", forkRepo.ID, ctxUser.Name, repo.Name) - ctx.JSONRedirect(ctxUser.HomeLink() + "/" + url.PathEscape(repo.Name)) + return repo } diff --git a/routers/web/repo/view_file.go b/routers/web/repo/view_file.go index ec0ad02828..5606a8e6ec 100644 --- a/routers/web/repo/view_file.go +++ b/routers/web/repo/view_file.go @@ -290,7 +290,7 @@ func prepareToRenderFile(ctx *context.Context, entry *git.TreeEntry) { func prepareToRenderButtons(ctx *context.Context, lfsLock *git_model.LFSLock) { // archived or mirror repository, the buttons should not be shown - if ctx.Repo.Repository.IsArchived || !ctx.Repo.Repository.CanEnableEditor() { + if !ctx.Repo.Repository.CanEnableEditor() { return } @@ -302,7 +302,9 @@ func prepareToRenderButtons(ctx *context.Context, lfsLock *git_model.LFSLock) { } if !ctx.Repo.CanWriteToBranch(ctx, ctx.Doer, ctx.Repo.BranchName) { + ctx.Data["CanEditFile"] = true ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.fork_before_edit") + ctx.Data["CanDeleteFile"] = true ctx.Data["DeleteFileTooltip"] = ctx.Tr("repo.editor.must_have_write_access") return } diff --git a/routers/web/repo/view_readme.go b/routers/web/repo/view_readme.go index 7af6ad450e..4ce22d79db 100644 --- a/routers/web/repo/view_readme.go +++ b/routers/web/repo/view_readme.go @@ -212,7 +212,7 @@ 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) { + if !fInfo.isLFSFile && ctx.Repo.Repository.CanEnableEditor() { ctx.Data["CanEditReadmeFile"] = true } } diff --git a/routers/web/web.go b/routers/web/web.go index 3040375def..4b5d68b260 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1313,23 +1313,35 @@ func registerWebRoutes(m *web.Router) { }, reqSignIn, context.RepoAssignment, context.RepoMustNotBeArchived()) // end "/{username}/{reponame}": create or edit issues, pulls, labels, milestones - m.Group("/{username}/{reponame}", func() { // repo code + m.Group("/{username}/{reponame}", func() { // repo code (at least "code reader") m.Group("", func() { m.Group("", func() { - m.Post("/_preview/*", repo.DiffPreviewPost) - m.Combo("/{editor_action:_edit}/*").Get(repo.EditFile). - Post(web.Bind(forms.EditRepoFileForm{}), repo.EditFilePost) - m.Combo("/{editor_action:_new}/*").Get(repo.EditFile). - Post(web.Bind(forms.EditRepoFileForm{}), repo.EditFilePost) - m.Combo("/_delete/*").Get(repo.DeleteFile). - Post(web.Bind(forms.DeleteRepoFileForm{}), repo.DeleteFilePost) - m.Combo("/_upload/*", repo.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("/_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) + // "GET" requests only need "code reader" permission, "POST" requests need "code writer" permission. + // Because reader can "fork and edit" + canWriteToBranch := context.CanWriteToBranch() + m.Post("/_preview/*", repo.DiffPreviewPost) // read-only, fine with "code reader" + m.Post("/_fork/*", repo.ForkToEditPost) // read-only, fork to own repo, fine with "code reader" + + // the path params are used in PrepareCommitFormOptions to construct the correct form action URL + m.Combo("/{editor_action:_edit}/*"). + Get(repo.EditFile). + Post(web.Bind(forms.EditRepoFileForm{}), canWriteToBranch, repo.EditFilePost) + m.Combo("/{editor_action:_new}/*"). + Get(repo.EditFile). + Post(web.Bind(forms.EditRepoFileForm{}), canWriteToBranch, repo.EditFilePost) + m.Combo("/{editor_action:_delete}/*"). + Get(repo.DeleteFile). + Post(web.Bind(forms.DeleteRepoFileForm{}), canWriteToBranch, repo.DeleteFilePost) + m.Combo("/{editor_action:_upload}/*", repo.MustBeAbleToUpload). + Get(repo.UploadFile). + Post(web.Bind(forms.UploadRepoFileForm{}), canWriteToBranch, repo.UploadFilePost) + m.Combo("/{editor_action:_diffpatch}/*"). + Get(repo.NewDiffPatch). + Post(web.Bind(forms.EditRepoFileForm{}), canWriteToBranch, repo.NewDiffPatchPost) + m.Combo("/{editor_action:_cherrypick}/{sha:([a-f0-9]{7,64})}/*"). + Get(repo.CherryPick). + Post(web.Bind(forms.CherryPickForm{}), canWriteToBranch, repo.CherryPickPost) + }, context.RepoRefByType(git.RefTypeBranch), repo.WebGitOperationCommonData) m.Group("", func() { m.Post("/upload-file", repo.UploadFileToServer) m.Post("/upload-remove", repo.RemoveUploadFileFromServer) diff --git a/services/context/repo.go b/services/context/repo.go index c28ae7e8fd..572211712b 100644 --- a/services/context/repo.go +++ b/services/context/repo.go @@ -71,11 +71,6 @@ func (r *Repository) CanWriteToBranch(ctx context.Context, user *user_model.User return issues_model.CanMaintainerWriteToBranch(ctx, r.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 -} - // CanCreateBranch returns true if repository is editable and user has proper access level. func (r *Repository) CanCreateBranch() bool { return r.Permission.CanWrite(unit_model.TypeCode) && r.Repository.CanCreateBranch() @@ -94,9 +89,13 @@ func RepoMustNotBeArchived() func(ctx *Context) { } } -type CommitFormBehaviors struct { +type CommitFormOptions struct { + NeedFork bool + + TargetRepo *repo_model.Repository + TargetFormAction string + WillSubmitToFork bool CanCommitToBranch bool - EditorEnabled bool UserCanPush bool RequireSigned bool WillSign bool @@ -106,51 +105,84 @@ type CommitFormBehaviors struct { CanCreateBasePullRequest bool } -func (r *Repository) PrepareCommitFormBehaviors(ctx *Context, doer *user_model.User) (*CommitFormBehaviors, error) { - protectedBranch, err := git_model.GetFirstMatchProtectedBranchRule(ctx, r.Repository.ID, r.BranchName) +func PrepareCommitFormOptions(ctx *Context, doer *user_model.User, targetRepo *repo_model.Repository, doerRepoPerm access_model.Permission, refName git.RefName) (*CommitFormOptions, error) { + if !refName.IsBranch() { + // it shouldn't happen because middleware already checks + return nil, util.NewInvalidArgumentErrorf("ref %q is not a branch", refName) + } + + originRepo := targetRepo + branchName := refName.ShortName() + // TODO: CanMaintainerWriteToBranch is a bad name, but it really does what "CanWriteToBranch" does + if !issues_model.CanMaintainerWriteToBranch(ctx, doerRepoPerm, branchName, doer) { + targetRepo = repo_model.GetForkedRepo(ctx, doer.ID, targetRepo.ID) + if targetRepo == nil { + return &CommitFormOptions{NeedFork: true}, nil + } + // now, we get our own forked repo; it must be writable by us. + } + submitToForkedRepo := targetRepo.ID != originRepo.ID + err := targetRepo.GetBaseRepo(ctx) if err != nil { return nil, err } - userCanPush := true - requireSigned := false + + protectedBranch, err := git_model.GetFirstMatchProtectedBranchRule(ctx, targetRepo.ID, branchName) + if err != nil { + return nil, err + } + canPushWithProtection := true + protectionRequireSigned := false if protectedBranch != nil { - protectedBranch.Repo = r.Repository - userCanPush = protectedBranch.CanUserPush(ctx, doer) - requireSigned = protectedBranch.RequireSignedCommits + protectedBranch.Repo = targetRepo + canPushWithProtection = protectedBranch.CanUserPush(ctx, doer) + protectionRequireSigned = protectedBranch.RequireSignedCommits } - sign, keyID, _, err := asymkey_service.SignCRUDAction(ctx, r.Repository.RepoPath(), doer, r.Repository.RepoPath(), git.BranchPrefix+r.BranchName) - - canEnableEditor := r.CanEnableEditor(ctx, doer) - canCommit := canEnableEditor && userCanPush - if requireSigned { - canCommit = canCommit && sign - } + willSign, signKeyID, _, err := asymkey_service.SignCRUDAction(ctx, targetRepo.RepoPath(), doer, targetRepo.RepoPath(), refName.String()) wontSignReason := "" - if err != nil { - if asymkey_service.IsErrWontSign(err) { - wontSignReason = string(err.(*asymkey_service.ErrWontSign).Reason) - err = nil - } else { - wontSignReason = "error" - } + if asymkey_service.IsErrWontSign(err) { + wontSignReason = string(err.(*asymkey_service.ErrWontSign).Reason) + } else if err != nil { + return nil, err + } + + canCommitToBranch := !submitToForkedRepo /* same repo */ && targetRepo.CanEnableEditor() && canPushWithProtection + if protectionRequireSigned { + canCommitToBranch = canCommitToBranch && willSign } - canCreateBasePullRequest := ctx.Repo.Repository.BaseRepo != nil && ctx.Repo.Repository.BaseRepo.UnitEnabled(ctx, unit_model.TypePullRequests) - canCreatePullRequest := ctx.Repo.Repository.UnitEnabled(ctx, unit_model.TypePullRequests) || canCreateBasePullRequest + canCreateBasePullRequest := targetRepo.BaseRepo != nil && targetRepo.BaseRepo.UnitEnabled(ctx, unit_model.TypePullRequests) + canCreatePullRequest := targetRepo.UnitEnabled(ctx, unit_model.TypePullRequests) || canCreateBasePullRequest - return &CommitFormBehaviors{ - CanCommitToBranch: canCommit, - EditorEnabled: canEnableEditor, - UserCanPush: userCanPush, - RequireSigned: requireSigned, - WillSign: sign, - SigningKey: keyID, + opts := &CommitFormOptions{ + TargetRepo: targetRepo, + WillSubmitToFork: submitToForkedRepo, + CanCommitToBranch: canCommitToBranch, + UserCanPush: canPushWithProtection, + RequireSigned: protectionRequireSigned, + WillSign: willSign, + SigningKey: signKeyID, WontSignReason: wontSignReason, CanCreatePullRequest: canCreatePullRequest, CanCreateBasePullRequest: canCreateBasePullRequest, - }, err + } + editorAction := ctx.PathParam("editor_action") + editorPathParamRemaining := util.PathEscapeSegments(branchName) + "/" + util.PathEscapeSegments(ctx.Repo.TreePath) + if submitToForkedRepo { + // there is only "default branch" in forked repo, we will use "from_base_branch" to get a new branch from base repo + editorPathParamRemaining = util.PathEscapeSegments(targetRepo.DefaultBranch) + "/" + util.PathEscapeSegments(ctx.Repo.TreePath) + "?from_base_branch=" + url.QueryEscape(branchName) + } + if editorAction == "_cherrypick" { + opts.TargetFormAction = targetRepo.Link() + "/" + editorAction + "/" + ctx.PathParam("sha") + "/" + editorPathParamRemaining + } else { + opts.TargetFormAction = targetRepo.Link() + "/" + editorAction + "/" + editorPathParamRemaining + } + if ctx.Req.URL.RawQuery != "" { + opts.TargetFormAction += util.Iif(strings.Contains(opts.TargetFormAction, "?"), "&", "?") + ctx.Req.URL.RawQuery + } + return opts, nil } // CanUseTimetracker returns whether a user can use the timetracker. diff --git a/services/context/upload/upload.go b/services/context/upload/upload.go index 303e7da38b..23707950d4 100644 --- a/services/context/upload/upload.go +++ b/services/context/upload/upload.go @@ -11,7 +11,9 @@ import ( "regexp" "strings" + repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/reqctx" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/services/context" ) @@ -106,14 +108,17 @@ func AddUploadContext(ctx *context.Context, uploadType string) { ctx.Data["UploadAccepts"] = strings.ReplaceAll(setting.Attachment.AllowedTypes, "|", ",") ctx.Data["UploadMaxFiles"] = setting.Attachment.MaxFiles ctx.Data["UploadMaxSize"] = setting.Attachment.MaxSize - case "repo": - ctx.Data["UploadUrl"] = ctx.Repo.RepoLink + "/upload-file" - ctx.Data["UploadRemoveUrl"] = ctx.Repo.RepoLink + "/upload-remove" - ctx.Data["UploadLinkUrl"] = ctx.Repo.RepoLink + "/upload-file" - ctx.Data["UploadAccepts"] = strings.ReplaceAll(setting.Repository.Upload.AllowedTypes, "|", ",") - ctx.Data["UploadMaxFiles"] = setting.Repository.Upload.MaxFiles - ctx.Data["UploadMaxSize"] = setting.Repository.Upload.FileMaxSize default: setting.PanicInDevOrTesting("Invalid upload type: %s", uploadType) } } + +func AddUploadContextForRepo(ctx reqctx.RequestContext, repo *repo_model.Repository) { + ctxData, repoLink := ctx.GetData(), repo.Link() + ctxData["UploadUrl"] = repoLink + "/upload-file" + ctxData["UploadRemoveUrl"] = repoLink + "/upload-remove" + ctxData["UploadLinkUrl"] = repoLink + "/upload-file" + ctxData["UploadAccepts"] = strings.ReplaceAll(setting.Repository.Upload.AllowedTypes, "|", ",") + ctxData["UploadMaxFiles"] = setting.Repository.Upload.MaxFiles + ctxData["UploadMaxSize"] = setting.Repository.Upload.FileMaxSize +} diff --git a/templates/repo/editor/cherry_pick.tmpl b/templates/repo/editor/cherry_pick.tmpl index f850ebf916..7981fd0761 100644 --- a/templates/repo/editor/cherry_pick.tmpl +++ b/templates/repo/editor/cherry_pick.tmpl @@ -3,8 +3,9 @@ {{template "repo/header" .}} <div class="ui container"> {{template "base/alert" .}} - <form class="ui edit form form-fetch-action" method="post" action="{{.RepoLink}}/_cherrypick/{{.FromCommitID}}/{{.BranchName | PathEscapeSegments}}"> + <form class="ui edit form form-fetch-action" method="post" action="{{.CommitFormOptions.TargetFormAction}}"> {{.CsrfTokenHtml}} + {{template "repo/editor/common_top" .}} <input type="hidden" name="revert" value="{{if eq .CherryPickType "revert"}}true{{else}}false{{end}}"> <div class="repo-editor-header"> <div class="breadcrumb"> diff --git a/templates/repo/editor/commit_form.tmpl b/templates/repo/editor/commit_form.tmpl index df1b9ac554..7067614444 100644 --- a/templates/repo/editor/commit_form.tmpl +++ b/templates/repo/editor/commit_form.tmpl @@ -1,11 +1,11 @@ <div class="commit-form-wrapper"> {{ctx.AvatarUtils.Avatar .SignedUser 40 "commit-avatar"}} <div class="commit-form"> - <h3>{{- if .CommitFormBehaviors.WillSign}} - <span title="{{ctx.Locale.Tr "repo.signing.will_sign" .CommitFormBehaviors.SigningKey}}">{{svg "octicon-lock" 24}}</span> + <h3>{{- if .CommitFormOptions.WillSign}} + <span title="{{ctx.Locale.Tr "repo.signing.will_sign" .CommitFormOptions.SigningKey}}">{{svg "octicon-lock" 24}}</span> {{ctx.Locale.Tr "repo.editor.commit_signed_changes"}} {{- else}} - <span title="{{ctx.Locale.Tr (printf "repo.signing.wont_sign.%s" .CommitFormBehaviors.WontSignReason)}}">{{svg "octicon-unlock" 24}}</span> + <span title="{{ctx.Locale.Tr (printf "repo.signing.wont_sign.%s" .CommitFormOptions.WontSignReason)}}">{{svg "octicon-unlock" 24}}</span> {{ctx.Locale.Tr "repo.editor.commit_changes"}} {{- end}}</h3> <div class="field"> @@ -22,17 +22,17 @@ </div> <div class="quick-pull-choice js-quick-pull-choice"> <div class="field"> - <div class="ui radio checkbox {{if not .CommitFormBehaviors.CanCommitToBranch}}disabled{{end}}"> + <div class="ui radio checkbox {{if not .CommitFormOptions.CanCommitToBranch}}disabled{{end}}"> <input type="radio" class="js-quick-pull-choice-option" name="commit_choice" value="direct" data-button-text="{{ctx.Locale.Tr "repo.editor.commit_changes"}}" {{if eq .commit_choice "direct"}}checked{{end}}> <label> {{svg "octicon-git-commit"}} {{ctx.Locale.Tr "repo.editor.commit_directly_to_this_branch" .BranchName}} - {{if not .CommitFormBehaviors.CanCommitToBranch}} + {{if not .CommitFormOptions.CanCommitToBranch}} <div class="ui visible small warning message"> {{ctx.Locale.Tr "repo.editor.no_commit_to_branch"}} <ul> - {{if not .CommitFormBehaviors.UserCanPush}}<li>{{ctx.Locale.Tr "repo.editor.user_no_push_to_branch"}}</li>{{end}} - {{if and .CommitFormBehaviors.RequireSigned (not .CommitFormBehaviors.WillSign)}}<li>{{ctx.Locale.Tr "repo.editor.require_signed_commit"}}</li>{{end}} + {{if not .CommitFormOptions.UserCanPush}}<li>{{ctx.Locale.Tr "repo.editor.user_no_push_to_branch"}}</li>{{end}} + {{if and .CommitFormOptions.RequireSigned (not .CommitFormOptions.WillSign)}}<li>{{ctx.Locale.Tr "repo.editor.require_signed_commit"}}</li>{{end}} </ul> </div> {{end}} @@ -42,14 +42,14 @@ {{if and (not .Repository.IsEmpty) (not .IsEditingFileOnly)}} <div class="field"> <div class="ui radio checkbox"> - {{if .CommitFormBehaviors.CanCreatePullRequest}} + {{if .CommitFormOptions.CanCreatePullRequest}} <input type="radio" class="js-quick-pull-choice-option" name="commit_choice" value="commit-to-new-branch" data-button-text="{{ctx.Locale.Tr "repo.editor.propose_file_change"}}" {{if eq .commit_choice "commit-to-new-branch"}}checked{{end}}> {{else}} <input type="radio" class="js-quick-pull-choice-option" name="commit_choice" value="commit-to-new-branch" data-button-text="{{ctx.Locale.Tr "repo.editor.commit_changes"}}" {{if eq .commit_choice "commit-to-new-branch"}}checked{{end}}> {{end}} <label> {{svg "octicon-git-pull-request"}} - {{if .CommitFormBehaviors.CanCreatePullRequest}} + {{if .CommitFormOptions.CanCreatePullRequest}} {{ctx.Locale.Tr "repo.editor.create_new_branch"}} {{else}} {{ctx.Locale.Tr "repo.editor.create_new_branch_np"}} diff --git a/templates/repo/editor/common_top.tmpl b/templates/repo/editor/common_top.tmpl new file mode 100644 index 0000000000..23280ed565 --- /dev/null +++ b/templates/repo/editor/common_top.tmpl @@ -0,0 +1,6 @@ +{{if .CommitFormOptions.WillSubmitToFork}} +<div class="ui blue message"> + {{$repoLinkHTML := HTMLFormat `<a href="%s">%s</a>` .CommitFormOptions.TargetRepo.Link .CommitFormOptions.TargetRepo.FullName}} + {{ctx.Locale.Tr "repo.editor.fork_edit_description" $repoLinkHTML}} +</div> +{{end}} diff --git a/templates/repo/editor/delete.tmpl b/templates/repo/editor/delete.tmpl index 6f9963a6bd..bf6143f1cb 100644 --- a/templates/repo/editor/delete.tmpl +++ b/templates/repo/editor/delete.tmpl @@ -3,8 +3,9 @@ {{template "repo/header" .}} <div class="ui container"> {{template "base/alert" .}} - <form class="ui form form-fetch-action" method="post"> + <form class="ui form form-fetch-action" method="post" action="{{.CommitFormOptions.TargetFormAction}}"> {{.CsrfTokenHtml}} + {{template "repo/editor/common_top" .}} {{template "repo/editor/commit_form" .}} </form> </div> diff --git a/templates/repo/editor/edit.tmpl b/templates/repo/editor/edit.tmpl index 536ed07ca7..0911d02e1f 100644 --- a/templates/repo/editor/edit.tmpl +++ b/templates/repo/editor/edit.tmpl @@ -3,11 +3,12 @@ {{template "repo/header" .}} <div class="ui container"> {{template "base/alert" .}} - <form class="ui edit form form-fetch-action" method="post" + <form class="ui edit form form-fetch-action" method="post" action="{{.CommitFormOptions.TargetFormAction}}" data-text-empty-confirm-header="{{ctx.Locale.Tr "repo.editor.commit_empty_file_header"}}" data-text-empty-confirm-content="{{ctx.Locale.Tr "repo.editor.commit_empty_file_text"}}" > {{.CsrfTokenHtml}} + {{template "repo/editor/common_top" .}} <div class="repo-editor-header"> {{template "repo/editor/common_breadcrumb" .}} </div> diff --git a/templates/repo/editor/fork.tmpl b/templates/repo/editor/fork.tmpl new file mode 100644 index 0000000000..e28b2ba7a2 --- /dev/null +++ b/templates/repo/editor/fork.tmpl @@ -0,0 +1,18 @@ +{{template "base/head" .}} +<div role="main" aria-label="{{.Title}}" class="page-content repository"> + {{template "repo/header" .}} + <div class="ui container"> + {{template "base/alert" .}} + <form class="ui form form-fetch-action" method="post" action="{{.RepoLink}}/_fork/{{.BranchName | PathEscapeSegments}}"> + {{.CsrfTokenHtml}} + <div class="tw-text-center"> + <div class="tw-my-[40px]"> + <h3>{{ctx.Locale.Tr "repo.editor.fork_create"}}</h3> + <p>{{ctx.Locale.Tr "repo.editor.fork_create_description"}}</p> + </div> + <button class="ui primary button">{{ctx.Locale.Tr "repo.fork_repo"}}</button> + </div> + </form> + </div> +</div> +{{template "base/footer" .}} diff --git a/templates/repo/editor/patch.tmpl b/templates/repo/editor/patch.tmpl index 7f9571b4ae..fa00edd92e 100644 --- a/templates/repo/editor/patch.tmpl +++ b/templates/repo/editor/patch.tmpl @@ -3,11 +3,12 @@ {{template "repo/header" .}} <div class="ui container"> {{template "base/alert" .}} - <form class="ui edit form form-fetch-action" method="post" action="{{.RepoLink}}/_diffpatch/{{.BranchName | PathEscapeSegments}}" + <form class="ui edit form form-fetch-action" method="post" action="{{.CommitFormOptions.TargetFormAction}}" data-text-empty-confirm-header="{{ctx.Locale.Tr "repo.editor.commit_empty_file_header"}}" data-text-empty-confirm-content="{{ctx.Locale.Tr "repo.editor.commit_empty_file_text"}}" > {{.CsrfTokenHtml}} + {{template "repo/editor/common_top" .}} <div class="repo-editor-header"> <div class="breadcrumb"> {{ctx.Locale.Tr "repo.editor.patching"}} diff --git a/templates/repo/editor/upload.tmpl b/templates/repo/editor/upload.tmpl index 2e9280e9cd..3e36c77b3b 100644 --- a/templates/repo/editor/upload.tmpl +++ b/templates/repo/editor/upload.tmpl @@ -3,8 +3,9 @@ {{template "repo/header" .}} <div class="ui container"> {{template "base/alert" .}} - <form class="ui comment form form-fetch-action" method="post"> + <form class="ui comment form form-fetch-action" method="post" action="{{.CommitFormOptions.TargetFormAction}}"> {{.CsrfTokenHtml}} + {{template "repo/editor/common_top" .}} <div class="repo-editor-header"> {{template "repo/editor/common_breadcrumb" .}} </div> diff --git a/templates/repo/view_content.tmpl b/templates/repo/view_content.tmpl index 2418f24d89..3ba04a9974 100644 --- a/templates/repo/view_content.tmpl +++ b/templates/repo/view_content.tmpl @@ -41,8 +41,8 @@ <a href="{{.Repository.Link}}/find/{{.RefTypeNameSubURL}}" class="ui compact basic button">{{ctx.Locale.Tr "repo.find_file.go_to_file"}}</a> {{end}} - {{if and .CanWriteCode .RefFullName.IsBranch (not .Repository.IsMirror) (not .Repository.IsArchived) (not .IsViewFile)}} - <button class="ui dropdown basic compact jump button"{{if not .Repository.CanEnableEditor}} disabled{{end}}> + {{if and .RefFullName.IsBranch (not .IsViewFile)}} + <button class="ui dropdown basic compact jump button repo-add-file" {{if not .Repository.CanEnableEditor}}disabled{{end}}> {{ctx.Locale.Tr "repo.editor.add_file"}} {{svg "octicon-triangle-down" 14 "dropdown icon"}} <div class="menu"> diff --git a/tests/integration/editor_test.go b/tests/integration/editor_test.go index 9e4ebb573f..ac47ed0094 100644 --- a/tests/integration/editor_test.go +++ b/tests/integration/editor_test.go @@ -7,6 +7,7 @@ import ( "bytes" "fmt" "io" + "maps" "mime/multipart" "net/http" "net/http/httptest" @@ -19,292 +20,278 @@ import ( "code.gitea.io/gitea/models/unittest" 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/modules/util" "code.gitea.io/gitea/tests" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestCreateFile(t *testing.T) { +func TestEditor(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") + sessionUser2 := loginUser(t, "user2") + t.Run("EditFileNotAllowed", testEditFileNotAllowed) + t.Run("DiffPreview", testEditorDiffPreview) + t.Run("CreateFile", testEditorCreateFile) + t.Run("EditFile", func(t *testing.T) { + testEditFile(t, sessionUser2, "user2", "repo1", "master", "README.md", "Hello, World (direct)\n") + testEditFileToNewBranch(t, sessionUser2, "user2", "repo1", "master", "feature/test", "README.md", "Hello, World (commit-to-new-branch)\n") + }) + t.Run("PatchFile", testEditorPatchFile) + t.Run("DeleteFile", func(t *testing.T) { + viewLink := "/user2/repo1/src/branch/branch2/README.md" + sessionUser2.MakeRequest(t, NewRequest(t, "GET", viewLink), http.StatusOK) + testEditorActionPostRequest(t, sessionUser2, "/user2/repo1/_delete/branch2/README.md", map[string]string{"commit_choice": "direct"}) + sessionUser2.MakeRequest(t, NewRequest(t, "GET", viewLink), http.StatusNotFound) + }) + t.Run("ForkToEditFile", func(t *testing.T) { + testForkToEditFile(t, loginUser(t, "user4"), "user4", "user2", "repo1", "master", "README.md") + }) + t.Run("WebGitCommitEmail", testEditorWebGitCommitEmail) + t.Run("ProtectedBranch", testEditorProtectedBranch) }) } -func testCreateFile(t *testing.T, session *TestSession, user, repo, branch, filePath, content string) { - // Request editor page - newURL := fmt.Sprintf("/%s/%s/_new/%s/", user, 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) +func testEditorCreateFile(t *testing.T) { + session := loginUser(t, "user2") + testCreateFile(t, session, "user2", "repo1", "master", "test.txt", "Content") + testEditorActionPostRequestError(t, session, "/user2/repo1/_new/master/", map[string]string{ + "tree_path": "test.txt", + "commit_choice": "direct", + "new_branch_name": "master", + }, `A file named "test.txt" already exists in this repository.`) + testEditorActionPostRequestError(t, session, "/user2/repo1/_new/master/", map[string]string{ + "tree_path": "test.txt", + "commit_choice": "commit-to-new-branch", + "new_branch_name": "master", + }, `Branch "master" already exists in this repository.`) +} - // Save new file to master branch - req = NewRequestWithValues(t, "POST", newURL, map[string]string{ - "_csrf": doc.GetCSRF(), - "last_commit": lastCommit, +func testCreateFile(t *testing.T, session *TestSession, user, repo, branch, filePath, content string) { + testEditorActionEdit(t, session, user, repo, "_new", branch, "", map[string]string{ "tree_path": filePath, "content": content, "commit_choice": "direct", }) - resp = session.MakeRequest(t, req, http.StatusOK) - assert.NotEmpty(t, test.RedirectURL(resp)) } -func TestCreateFileOnProtectedBranch(t *testing.T) { - onGiteaRun(t, func(t *testing.T, u *url.URL) { - session := loginUser(t, "user2") - - 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) - - // Request editor page - req = NewRequest(t, "GET", "/user2/repo1/_new/master/") - 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", "/user2/repo1/_new/master/", map[string]string{ - "_csrf": doc.GetCSRF(), - "last_commit": lastCommit, - "tree_path": "test.txt", - "content": "Content", - "commit_choice": "direct", - }) - - resp = session.MakeRequest(t, req, http.StatusBadRequest) - respErr := test.ParseJSONError(resp.Body.Bytes()) - assert.Equal(t, `Cannot commit to protected branch "master".`, respErr.ErrorMessage) - - // remove the protected branch - csrf = GetUserCSRFToken(t, session) - - // Change master branch to protected - req = NewRequestWithValues(t, "POST", "/user2/repo1/settings/branches/1/delete", map[string]string{ - "_csrf": csrf, - }) - - 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"]) - - // Check if master branch has been locked successfully - flashMsg = session.GetCookieFlashMessage() - assert.Equal(t, `Removing branch protection rule "1" failed.`, flashMsg.ErrorMsg) +func testEditorProtectedBranch(t *testing.T) { + session := loginUser(t, "user2") + // Change the "master" branch to "protected" + req := NewRequestWithValues(t, "POST", "/user2/repo1/settings/branches/edit", map[string]string{ + "_csrf": GetUserCSRFToken(t, session), + "rule_name": "master", + "enable_push": "true", }) + session.MakeRequest(t, req, http.StatusSeeOther) + flashMsg := session.GetCookieFlashMessage() + assert.Equal(t, `Branch protection for rule "master" has been updated.`, flashMsg.SuccessMsg) + + // Try to commit a file to the "master" branch and it should fail + resp := testEditorActionPostRequest(t, session, "/user2/repo1/_new/master/", map[string]string{"tree_path": "test-protected-branch.txt", "commit_choice": "direct"}) + assert.Equal(t, http.StatusBadRequest, resp.Code) + assert.Equal(t, `Cannot commit to protected branch "master".`, test.ParseJSONError(resp.Body.Bytes()).ErrorMessage) } -func testEditFile(t *testing.T, session *TestSession, user, repo, branch, filePath, newContent string) *httptest.ResponseRecorder { - // Get to the 'edit this file' page - req := NewRequest(t, "GET", path.Join(user, repo, "_edit", branch, filePath)) +func testEditorActionPostRequest(t *testing.T, session *TestSession, requestPath string, params map[string]string) *httptest.ResponseRecorder { + req := NewRequest(t, "GET", requestPath) resp := session.MakeRequest(t, req, http.StatusOK) - htmlDoc := NewHTMLParser(t, resp.Body) - lastCommit := htmlDoc.GetInputValueByName("last_commit") - assert.NotEmpty(t, lastCommit) - - // Submit the edits - req = NewRequestWithValues(t, "POST", path.Join(user, repo, "_edit", branch, filePath), - map[string]string{ - "_csrf": htmlDoc.GetCSRF(), - "last_commit": lastCommit, - "tree_path": filePath, - "content": newContent, - "commit_choice": "direct", - }, - ) - resp = session.MakeRequest(t, req, http.StatusOK) - assert.NotEmpty(t, test.RedirectURL(resp)) - - // Verify the change - req = NewRequest(t, "GET", path.Join(user, repo, "raw/branch", branch, filePath)) - resp = session.MakeRequest(t, req, http.StatusOK) - assert.Equal(t, newContent, resp.Body.String()) - - return resp + form := map[string]string{ + "_csrf": htmlDoc.GetCSRF(), + "last_commit": htmlDoc.GetInputValueByName("last_commit"), + } + maps.Copy(form, params) + req = NewRequestWithValues(t, "POST", requestPath, form) + return session.MakeRequest(t, req, NoExpectedStatus) } -func testEditFileToNewBranch(t *testing.T, session *TestSession, user, 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)) - resp := session.MakeRequest(t, req, http.StatusOK) +func testEditorActionPostRequestError(t *testing.T, session *TestSession, requestPath string, params map[string]string, errorMessage string) { + resp := testEditorActionPostRequest(t, session, requestPath, params) + assert.Equal(t, http.StatusBadRequest, resp.Code) + assert.Equal(t, errorMessage, test.ParseJSONError(resp.Body.Bytes()).ErrorMessage) +} - htmlDoc := NewHTMLParser(t, resp.Body) - lastCommit := htmlDoc.GetInputValueByName("last_commit") - assert.NotEmpty(t, lastCommit) - - // Submit the edits - req = NewRequestWithValues(t, "POST", path.Join(user, repo, "_edit", branch, filePath), - map[string]string{ - "_csrf": htmlDoc.GetCSRF(), - "last_commit": lastCommit, - "tree_path": filePath, - "content": newContent, - "commit_choice": "commit-to-new-branch", - "new_branch_name": targetBranch, - }, - ) - resp = session.MakeRequest(t, req, http.StatusOK) +func testEditorActionEdit(t *testing.T, session *TestSession, user, repo, editorAction, branch, filePath string, params map[string]string) *httptest.ResponseRecorder { + params["tree_path"] = util.IfZero(params["tree_path"], filePath) + newBranchName := util.Iif(params["commit_choice"] == "direct", branch, params["new_branch_name"]) + resp := testEditorActionPostRequest(t, session, fmt.Sprintf("/%s/%s/%s/%s/%s", user, repo, editorAction, branch, filePath), params) + assert.Equal(t, http.StatusOK, resp.Code) assert.NotEmpty(t, test.RedirectURL(resp)) - - // Verify the change - req = NewRequest(t, "GET", path.Join(user, repo, "raw/branch", targetBranch, filePath)) + req := NewRequest(t, "GET", path.Join(user, repo, "raw/branch", newBranchName, params["tree_path"])) resp = session.MakeRequest(t, req, http.StatusOK) - assert.Equal(t, newContent, resp.Body.String()) - + assert.Equal(t, params["content"], resp.Body.String()) return resp } -func TestEditFile(t *testing.T) { - onGiteaRun(t, func(t *testing.T, u *url.URL) { - session := loginUser(t, "user2") - testEditFile(t, session, "user2", "repo1", "master", "README.md", "Hello, World (Edited)\n") +func testEditFile(t *testing.T, session *TestSession, user, repo, branch, filePath, newContent string) { + testEditorActionEdit(t, session, user, repo, "_edit", branch, filePath, map[string]string{ + "content": newContent, + "commit_choice": "direct", }) } -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") +func testEditFileToNewBranch(t *testing.T, session *TestSession, user, repo, branch, targetBranch, filePath, newContent string) { + testEditorActionEdit(t, session, user, repo, "_edit", branch, filePath, map[string]string{ + "content": newContent, + "commit_choice": "commit-to-new-branch", + "new_branch_name": targetBranch, }) } -func TestWebGitCommitEmail(t *testing.T) { - onGiteaRun(t, func(t *testing.T, _ *url.URL) { - user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) - require.True(t, user.KeepEmailPrivate) - - repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) - gitRepo, _ := git.OpenRepository(git.DefaultContext, repo1.RepoPath()) - defer gitRepo.Close() - getLastCommit := func(t *testing.T) *git.Commit { - c, err := gitRepo.GetBranchCommit("master") - require.NoError(t, err) - return c - } +func testEditorDiffPreview(t *testing.T) { + session := loginUser(t, "user2") + req := NewRequestWithValues(t, "POST", "/user2/repo1/_preview/master/README.md", map[string]string{ + "_csrf": GetUserCSRFToken(t, session), + "content": "Hello, World (Edited)\n", + }) + resp := session.MakeRequest(t, req, http.StatusOK) + assert.Contains(t, resp.Body.String(), `<span class="added-code">Hello, World (Edited)</span>`) +} - session := loginUser(t, user.Name) - - makeReq := func(t *testing.T, link string, params map[string]string, expectedUserName, expectedEmail string) *httptest.ResponseRecorder { - lastCommit := getLastCommit(t) - params["_csrf"] = GetUserCSRFToken(t, session) - params["last_commit"] = lastCommit.ID.String() - params["commit_choice"] = "direct" - req := NewRequestWithValues(t, "POST", link, params) - resp := session.MakeRequest(t, req, NoExpectedStatus) - newCommit := getLastCommit(t) - if expectedUserName == "" { - require.Equal(t, lastCommit.ID.String(), newCommit.ID.String()) - respErr := test.ParseJSONError(resp.Body.Bytes()) - assert.Equal(t, translation.NewLocale("en-US").TrString("repo.editor.invalid_commit_email"), respErr.ErrorMessage) - } else { - require.NotEqual(t, lastCommit.ID.String(), newCommit.ID.String()) - assert.Equal(t, expectedUserName, newCommit.Author.Name) - assert.Equal(t, expectedEmail, newCommit.Author.Email) - assert.Equal(t, expectedUserName, newCommit.Committer.Name) - assert.Equal(t, expectedEmail, newCommit.Committer.Email) - } - return resp - } +func testEditorPatchFile(t *testing.T) { + session := loginUser(t, "user2") + pathContentCommon := `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 @@ ++` + testEditorActionPostRequest(t, session, "/user2/repo1/_diffpatch/master/", map[string]string{ + "content": pathContentCommon + "patched content\n", + "commit_choice": "commit-to-new-branch", + "new_branch_name": "patched-branch", + }) + resp := MakeRequest(t, NewRequest(t, "GET", "/user2/repo1/raw/branch/patched-branch/patch-file-1.txt"), http.StatusOK) + assert.Equal(t, "patched content\n", resp.Body.String()) + + // patch again, it should fail + resp = testEditorActionPostRequest(t, session, "/user2/repo1/_diffpatch/patched-branch/", map[string]string{ + "content": pathContentCommon + "another patched content\n", + "commit_choice": "commit-to-new-branch", + "new_branch_name": "patched-branch-1", + }) + assert.Equal(t, "Unable to apply patch", test.ParseJSONError(resp.Body.Bytes()).ErrorMessage) +} - uploadFile := func(t *testing.T, name, content string) string { - body := &bytes.Buffer{} - uploadForm := multipart.NewWriter(body) - file, _ := uploadForm.CreateFormFile("file", name) - _, _ = io.Copy(file, strings.NewReader(content)) - _ = uploadForm.WriteField("_csrf", GetUserCSRFToken(t, session)) - _ = uploadForm.Close() - - req := NewRequestWithBody(t, "POST", "/user2/repo1/upload-file", body) - req.Header.Add("Content-Type", uploadForm.FormDataContentType()) - resp := session.MakeRequest(t, req, http.StatusOK) - - respMap := map[string]string{} - DecodeJSON(t, resp, &respMap) - return respMap["uuid"] +func testEditorWebGitCommitEmail(t *testing.T) { + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + require.True(t, user.KeepEmailPrivate) + + repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + gitRepo, _ := git.OpenRepository(git.DefaultContext, repo1.RepoPath()) + defer gitRepo.Close() + getLastCommit := func(t *testing.T) *git.Commit { + c, err := gitRepo.GetBranchCommit("master") + require.NoError(t, err) + return c + } + + session := loginUser(t, user.Name) + + makeReq := func(t *testing.T, link string, params map[string]string, expectedUserName, expectedEmail string) *httptest.ResponseRecorder { + lastCommit := getLastCommit(t) + params["_csrf"] = GetUserCSRFToken(t, session) + params["last_commit"] = lastCommit.ID.String() + params["commit_choice"] = "direct" + req := NewRequestWithValues(t, "POST", link, params) + resp := session.MakeRequest(t, req, NoExpectedStatus) + newCommit := getLastCommit(t) + if expectedUserName == "" { + require.Equal(t, lastCommit.ID.String(), newCommit.ID.String()) + respErr := test.ParseJSONError(resp.Body.Bytes()) + assert.Equal(t, translation.NewLocale("en-US").TrString("repo.editor.invalid_commit_email"), respErr.ErrorMessage) + } else { + require.NotEqual(t, lastCommit.ID.String(), newCommit.ID.String()) + assert.Equal(t, expectedUserName, newCommit.Author.Name) + assert.Equal(t, expectedEmail, newCommit.Author.Email) + assert.Equal(t, expectedUserName, newCommit.Committer.Name) + assert.Equal(t, expectedEmail, newCommit.Committer.Email) } + return resp + } + + uploadFile := func(t *testing.T, name, content string) string { + body := &bytes.Buffer{} + uploadForm := multipart.NewWriter(body) + file, _ := uploadForm.CreateFormFile("file", name) + _, _ = io.Copy(file, strings.NewReader(content)) + _ = uploadForm.WriteField("_csrf", GetUserCSRFToken(t, session)) + _ = uploadForm.Close() + + req := NewRequestWithBody(t, "POST", "/user2/repo1/upload-file", body) + req.Header.Add("Content-Type", uploadForm.FormDataContentType()) + resp := session.MakeRequest(t, req, http.StatusOK) - t.Run("EmailInactive", func(t *testing.T) { - defer tests.PrintCurrentTest(t)() - email := unittest.AssertExistsAndLoadBean(t, &user_model.EmailAddress{ID: 35, UID: user.ID}) - require.False(t, email.IsActivated) - makeReq(t, "/user2/repo1/_edit/master/README.md", map[string]string{ - "tree_path": "README.md", - "content": "test content", - "commit_email": email.Email, - }, "", "") - }) + respMap := map[string]string{} + DecodeJSON(t, resp, &respMap) + return respMap["uuid"] + } + + t.Run("EmailInactive", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + email := unittest.AssertExistsAndLoadBean(t, &user_model.EmailAddress{ID: 35, UID: user.ID}) + require.False(t, email.IsActivated) + makeReq(t, "/user2/repo1/_edit/master/README.md", map[string]string{ + "tree_path": "README.md", + "content": "test content", + "commit_email": email.Email, + }, "", "") + }) + + t.Run("EmailInvalid", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + email := unittest.AssertExistsAndLoadBean(t, &user_model.EmailAddress{ID: 1, IsActivated: true}) + require.NotEqual(t, email.UID, user.ID) + makeReq(t, "/user2/repo1/_edit/master/README.md", map[string]string{ + "tree_path": "README.md", + "content": "test content", + "commit_email": email.Email, + }, "", "") + }) - t.Run("EmailInvalid", func(t *testing.T) { + testWebGit := func(t *testing.T, linkForKeepPrivate string, paramsForKeepPrivate map[string]string, linkForChosenEmail string, paramsForChosenEmail map[string]string) (resp1, resp2 *httptest.ResponseRecorder) { + t.Run("DefaultEmailKeepPrivate", func(t *testing.T) { defer tests.PrintCurrentTest(t)() - email := unittest.AssertExistsAndLoadBean(t, &user_model.EmailAddress{ID: 1, IsActivated: true}) - require.NotEqual(t, email.UID, user.ID) - makeReq(t, "/user2/repo1/_edit/master/README.md", map[string]string{ - "tree_path": "README.md", - "content": "test content", - "commit_email": email.Email, - }, "", "") + paramsForKeepPrivate["commit_email"] = "" + resp1 = makeReq(t, linkForKeepPrivate, paramsForKeepPrivate, "User Two", "user2@noreply.example.org") }) - - testWebGit := func(t *testing.T, linkForKeepPrivate string, paramsForKeepPrivate map[string]string, linkForChosenEmail string, paramsForChosenEmail map[string]string) (resp1, resp2 *httptest.ResponseRecorder) { - t.Run("DefaultEmailKeepPrivate", func(t *testing.T) { - defer tests.PrintCurrentTest(t)() - paramsForKeepPrivate["commit_email"] = "" - resp1 = makeReq(t, linkForKeepPrivate, paramsForKeepPrivate, "User Two", "user2@noreply.example.org") - }) - t.Run("ChooseEmail", func(t *testing.T) { - defer tests.PrintCurrentTest(t)() - paramsForChosenEmail["commit_email"] = "user2@example.com" - resp2 = makeReq(t, linkForChosenEmail, paramsForChosenEmail, "User Two", "user2@example.com") - }) - return resp1, resp2 - } - - t.Run("Edit", func(t *testing.T) { - testWebGit(t, - "/user2/repo1/_edit/master/README.md", map[string]string{"tree_path": "README.md", "content": "for keep private"}, - "/user2/repo1/_edit/master/README.md", map[string]string{"tree_path": "README.md", "content": "for chosen email"}, - ) + t.Run("ChooseEmail", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + paramsForChosenEmail["commit_email"] = "user2@example.com" + resp2 = makeReq(t, linkForChosenEmail, paramsForChosenEmail, "User Two", "user2@example.com") }) + return resp1, resp2 + } + + t.Run("Edit", func(t *testing.T) { + testWebGit(t, + "/user2/repo1/_edit/master/README.md", map[string]string{"tree_path": "README.md", "content": "for keep private"}, + "/user2/repo1/_edit/master/README.md", map[string]string{"tree_path": "README.md", "content": "for chosen email"}, + ) + }) - t.Run("UploadDelete", func(t *testing.T) { - file1UUID := uploadFile(t, "file1", "File 1") - file2UUID := uploadFile(t, "file2", "File 2") - testWebGit(t, - "/user2/repo1/_upload/master", map[string]string{"files": file1UUID}, - "/user2/repo1/_upload/master", map[string]string{"files": file2UUID}, - ) - testWebGit(t, - "/user2/repo1/_delete/master/file1", map[string]string{}, - "/user2/repo1/_delete/master/file2", map[string]string{}, - ) - }) + t.Run("UploadDelete", func(t *testing.T) { + file1UUID := uploadFile(t, "file1", "File 1") + file2UUID := uploadFile(t, "file2", "File 2") + testWebGit(t, + "/user2/repo1/_upload/master", map[string]string{"files": file1UUID}, + "/user2/repo1/_upload/master", map[string]string{"files": file2UUID}, + ) + testWebGit(t, + "/user2/repo1/_delete/master/file1", map[string]string{}, + "/user2/repo1/_delete/master/file2", map[string]string{}, + ) + }) - t.Run("ApplyPatchCherryPick", func(t *testing.T) { - testWebGit(t, - "/user2/repo1/_diffpatch/master", map[string]string{ - "tree_path": "__dummy__", - "content": `diff --git a/patch-file-1.txt b/patch-file-1.txt + t.Run("ApplyPatchCherryPick", func(t *testing.T) { + testWebGit(t, + "/user2/repo1/_diffpatch/master", map[string]string{ + "tree_path": "__dummy__", + "content": `diff --git a/patch-file-1.txt b/patch-file-1.txt new file mode 100644 index 0000000000..aaaaaaaaaa --- /dev/null @@ -312,10 +299,10 @@ index 0000000000..aaaaaaaaaa @@ -0,0 +1 @@ +File 1 `, - }, - "/user2/repo1/_diffpatch/master", map[string]string{ - "tree_path": "__dummy__", - "content": `diff --git a/patch-file-2.txt b/patch-file-2.txt + }, + "/user2/repo1/_diffpatch/master", map[string]string{ + "tree_path": "__dummy__", + "content": `diff --git a/patch-file-2.txt b/patch-file-2.txt new file mode 100644 index 0000000000..bbbbbbbbbb --- /dev/null @@ -323,20 +310,146 @@ index 0000000000..bbbbbbbbbb @@ -0,0 +1 @@ +File 2 `, - }, - ) - - commit1, err := gitRepo.GetCommitByPath("patch-file-1.txt") - require.NoError(t, err) - commit2, err := gitRepo.GetCommitByPath("patch-file-2.txt") - require.NoError(t, err) - resp1, _ := testWebGit(t, - "/user2/repo1/_cherrypick/"+commit1.ID.String()+"/master", map[string]string{"revert": "true"}, - "/user2/repo1/_cherrypick/"+commit2.ID.String()+"/master", map[string]string{"revert": "true"}, - ) - - // By the way, test the "cherrypick" page: a successful revert redirects to the main branch - assert.Equal(t, "/user2/repo1/src/branch/master", test.RedirectURL(resp1)) - }) + }, + ) + + commit1, err := gitRepo.GetCommitByPath("patch-file-1.txt") + require.NoError(t, err) + commit2, err := gitRepo.GetCommitByPath("patch-file-2.txt") + require.NoError(t, err) + resp1, _ := testWebGit(t, + "/user2/repo1/_cherrypick/"+commit1.ID.String()+"/master", map[string]string{"revert": "true"}, + "/user2/repo1/_cherrypick/"+commit2.ID.String()+"/master", map[string]string{"revert": "true"}, + ) + + // By the way, test the "cherrypick" page: a successful revert redirects to the main branch + assert.Equal(t, "/user2/repo1/src/branch/master", test.RedirectURL(resp1)) + }) +} + +func testForkToEditFile(t *testing.T, session *TestSession, user, owner, repo, branch, filePath string) { + forkToEdit := func(t *testing.T, session *TestSession, owner, repo, operation, branch, filePath string) { + // visit the base repo, see the "Add File" button + req := NewRequest(t, "GET", path.Join(owner, repo)) + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + AssertHTMLElement(t, htmlDoc, ".repo-add-file", 1) + + // attempt to edit a file, see the guideline page + req = NewRequest(t, "GET", path.Join(owner, repo, operation, branch, filePath)) + resp = session.MakeRequest(t, req, http.StatusOK) + assert.Contains(t, resp.Body.String(), "Fork Repository to Propose Changes") + + // fork the repository + req = NewRequestWithValues(t, "POST", path.Join(owner, repo, "_fork", branch), map[string]string{"_csrf": GetUserCSRFToken(t, session)}) + resp = session.MakeRequest(t, req, http.StatusOK) + assert.JSONEq(t, `{"redirect":""}`, resp.Body.String()) + } + + t.Run("ForkButArchived", func(t *testing.T) { + // Fork repository because we can't edit it + forkToEdit(t, session, owner, repo, "_edit", branch, filePath) + + // Archive the repository + req := NewRequestWithValues(t, "POST", path.Join(user, repo, "settings"), + map[string]string{ + "_csrf": GetUserCSRFToken(t, session), + "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)).SetHeader("Accept", "text/html") + resp := session.MakeRequest(t, req, http.StatusNotFound) + assert.Contains(t, resp.Body.String(), "You have forked this repository but your fork is not editable.") + + // Unfork the repository + req = NewRequestWithValues(t, "POST", path.Join(user, repo, "settings"), + map[string]string{ + "_csrf": GetUserCSRFToken(t, session), + "repo_name": repo, + "action": "convert_fork", + }, + ) + session.MakeRequest(t, req, http.StatusSeeOther) }) + + // Fork repository again, and check the existence of the forked repo with unique name + forkToEdit(t, session, owner, repo, "_edit", branch, filePath) + session.MakeRequest(t, NewRequestf(t, "GET", "/%s/%s-1", user, repo), http.StatusOK) + + t.Run("CheckBaseRepoForm", func(t *testing.T) { + // the base repo's edit form should have the correct action and upload links (pointing to the forked repo) + req := NewRequest(t, "GET", path.Join(owner, repo, "_upload", branch, filePath)+"?foo=bar") + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + uploadForm := htmlDoc.doc.Find(".form-fetch-action") + formAction := uploadForm.AttrOr("action", "") + assert.Equal(t, fmt.Sprintf("/%s/%s-1/_upload/%s/%s?from_base_branch=%s&foo=bar", user, repo, branch, filePath, branch), formAction) + uploadLink := uploadForm.Find(".dropzone").AttrOr("data-link-url", "") + assert.Equal(t, fmt.Sprintf("/%s/%s-1/upload-file", user, repo), uploadLink) + newBranchName := uploadForm.Find("input[name=new_branch_name]").AttrOr("value", "") + assert.Equal(t, user+"-patch-1", newBranchName) + commitChoice := uploadForm.Find("input[name=commit_choice][checked]").AttrOr("value", "") + assert.Equal(t, "commit-to-new-branch", commitChoice) + lastCommit := uploadForm.Find("input[name=last_commit]").AttrOr("value", "") + assert.NotEmpty(t, lastCommit) + }) + + t.Run("ViewBaseEditFormAndCommitToFork", func(t *testing.T) { + req := NewRequest(t, "GET", path.Join(owner, repo, "_edit", branch, filePath)) + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + editRequestForm := map[string]string{ + "_csrf": GetUserCSRFToken(t, session), + "last_commit": htmlDoc.GetInputValueByName("last_commit"), + "tree_path": filePath, + "content": "new content in fork", + "commit_choice": "commit-to-new-branch", + } + // change a file in the forked repo with existing branch name (should fail) + editRequestForm["new_branch_name"] = "master" + req = NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s-1/_edit/%s/%s?from_base_branch=%s", user, repo, branch, filePath, branch), editRequestForm) + resp = session.MakeRequest(t, req, http.StatusBadRequest) + respJSON := test.ParseJSONError(resp.Body.Bytes()) + assert.Equal(t, `Branch "master" already exists in your fork, please choose a new branch name.`, respJSON.ErrorMessage) + + // change a file in the forked repo (should succeed) + newBranchName := htmlDoc.GetInputValueByName("new_branch_name") + editRequestForm["new_branch_name"] = newBranchName + req = NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s-1/_edit/%s/%s?from_base_branch=%s", user, repo, branch, filePath, branch), editRequestForm) + resp = session.MakeRequest(t, req, http.StatusOK) + assert.Equal(t, fmt.Sprintf("/%s/%s/compare/%s...%s/%s-1:%s", owner, repo, branch, user, repo, newBranchName), test.RedirectURL(resp)) + + // check the file in the fork's branch is changed + req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s-1/src/branch/%s/%s", user, repo, newBranchName, filePath)) + resp = session.MakeRequest(t, req, http.StatusOK) + assert.Contains(t, resp.Body.String(), "new content in fork") + }) +} + +func testEditFileNotAllowed(t *testing.T) { + sessionUser1 := loginUser(t, "user1") // admin, all access + sessionUser4 := loginUser(t, "user4") + // "_cherrypick" has a different route pattern, so skip its test + operations := []string{"_new", "_edit", "_delete", "_upload", "_diffpatch"} + for _, operation := range operations { + t.Run(operation, func(t *testing.T) { + // Branch does not exist + targetLink := path.Join("user2", "repo1", operation, "missing", "README.md") + sessionUser1.MakeRequest(t, NewRequest(t, "GET", targetLink), http.StatusNotFound) + + // Private repository + targetLink = path.Join("user2", "repo2", operation, "master", "Home.md") + sessionUser1.MakeRequest(t, NewRequest(t, "GET", targetLink), http.StatusOK) + sessionUser4.MakeRequest(t, NewRequest(t, "GET", targetLink), http.StatusNotFound) + + // Empty repository + targetLink = path.Join("org41", "repo61", operation, "master", "README.md") + sessionUser1.MakeRequest(t, NewRequest(t, "GET", targetLink), http.StatusNotFound) + }) + } } diff --git a/tests/integration/html_helper.go b/tests/integration/html_helper.go index 874fc32228..4d589b32e7 100644 --- a/tests/integration/html_helper.go +++ b/tests/integration/html_helper.go @@ -42,7 +42,7 @@ func (doc *HTMLDoc) GetCSRF() string { return doc.GetInputValueByName("_csrf") } -// AssertHTMLElement check if element by selector exists or does not exist depending on checkExists +// AssertHTMLElement check if the element by selector exists or does not exist depending on checkExists func AssertHTMLElement[T int | bool](t testing.TB, doc *HTMLDoc, selector string, checkExists T) { sel := doc.doc.Find(selector) switch v := any(checkExists).(type) { diff --git a/tests/integration/pull_status_test.go b/tests/integration/pull_status_test.go index bfcb97b082..49326a594a 100644 --- a/tests/integration/pull_status_test.go +++ b/tests/integration/pull_status_test.go @@ -129,7 +129,7 @@ func TestPullCreate_EmptyChangesWithDifferentCommits(t *testing.T) { 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") + 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/web_src/js/features/common-fetch-action.ts b/web_src/js/features/common-fetch-action.ts index 9359713454..a372216ae6 100644 --- a/web_src/js/features/common-fetch-action.ts +++ b/web_src/js/features/common-fetch-action.ts @@ -5,7 +5,7 @@ import {confirmModal} from './comp/ConfirmModal.ts'; import type {RequestOpts} from '../types.ts'; import {ignoreAreYouSure} from '../vendor/jquery.are-you-sure.ts'; -const {appSubUrl, i18n} = window.config; +const {appSubUrl} = window.config; // fetchActionDoRedirect does real redirection to bypass the browser's limitations of "location" // more details are in the backend's fetch-redirect handler @@ -23,11 +23,20 @@ function fetchActionDoRedirect(redirect: string) { } async function fetchActionDoRequest(actionElem: HTMLElement, url: string, opt: RequestOpts) { + const showErrorForResponse = (code: number, message: string) => { + showErrorToast(`Error ${code || 'request'}: ${message}`); + }; + + let respStatus = 0; + let respText = ''; try { hideToastsAll(); const resp = await request(url, opt); - if (resp.status === 200) { - let {redirect} = await resp.json(); + respStatus = resp.status; + respText = await resp.text(); + const respJson = JSON.parse(respText); + if (respStatus === 200) { + let {redirect} = respJson; redirect = redirect || actionElem.getAttribute('data-redirect'); ignoreAreYouSure(actionElem); // ignore the areYouSure check before reloading if (redirect) { @@ -38,22 +47,19 @@ async function fetchActionDoRequest(actionElem: HTMLElement, url: string, opt: R return; } - if (resp.status >= 400 && resp.status < 500) { - const data = await resp.json(); + if (respStatus >= 400 && respStatus < 500 && respJson?.errorMessage) { // the code was quite messy, sometimes the backend uses "err", sometimes it uses "error", and even "user_error" // but at the moment, as a new approach, we only use "errorMessage" here, backend can use JSONError() to respond. - if (data.errorMessage) { - showErrorToast(data.errorMessage, {useHtmlBody: data.renderFormat === 'html'}); - } else { - showErrorToast(`server error: ${resp.status}`); - } + showErrorToast(respJson.errorMessage, {useHtmlBody: respJson.renderFormat === 'html'}); } else { - showErrorToast(`server error: ${resp.status}`); + showErrorForResponse(respStatus, respText); } } catch (e) { - if (e.name !== 'AbortError') { - console.error('error when doRequest', e); - showErrorToast(`${i18n.network_error} ${e}`); + if (e.name === 'SyntaxError') { + showErrorForResponse(respStatus, (respText || '').substring(0, 100)); + } else if (e.name !== 'AbortError') { + console.error('fetchActionDoRequest error', e); + showErrorForResponse(respStatus, `${e}`); } } actionElem.classList.remove('is-loading', 'loading-icon-2px'); |