Files
gitea/routers/web/repo/fork.go
T
somaz 140fac63e2 fix(repo): honor ALLOW_FORK_INTO_SAME_OWNER for organizations (#40025)
With `[repository] ALLOW_FORK_INTO_SAME_OWNER = true` a user can fork
their own repository, but an organization still cannot fork a repository
it owns. The fork button check and the owner list on the fork page
compared the organization with the repository owner directly instead of
asking `CanUserForkBetweenOwners`, and the walk over parent repositories
dropped the owning organization whenever the setting was on.
Organizations now follow the setting the same way users already do, and
nothing changes when it is off.

Fixes https://github.com/go-gitea/gitea/issues/40021
Assisted-by: Claude Code:claude-opus-5-5
2026-10-30 17:28:47 +00:00

238 lines
7.0 KiB
Go

// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repo
import (
"errors"
"net/http"
"net/url"
"slices"
"gitea.dev/models/db"
git_model "gitea.dev/models/git"
"gitea.dev/models/organization"
repo_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
"gitea.dev/modules/log"
"gitea.dev/modules/optional"
"gitea.dev/modules/repository"
"gitea.dev/modules/setting"
"gitea.dev/modules/structs"
"gitea.dev/modules/templates"
"gitea.dev/modules/web"
"gitea.dev/services/context"
"gitea.dev/services/forms"
repo_service "gitea.dev/services/repository"
)
const (
tplFork templates.TplName = "repo/pulls/fork"
)
func getForkRepository(ctx *context.Context) *repo_model.Repository {
forkRepo := ctx.Repo.Repository
if ctx.Written() {
return nil
}
if forkRepo.IsEmpty {
log.Trace("Empty repository %-v", forkRepo)
ctx.NotFound(nil)
return nil
}
if err := forkRepo.LoadOwner(ctx); err != nil {
ctx.ServerError("LoadOwner", err)
return nil
}
ctx.Data["repo_name"] = forkRepo.Name
ctx.Data["description"] = forkRepo.Description
ctx.Data["IsPrivate"] = forkRepo.IsPrivate || forkRepo.Owner.Visibility == structs.VisibleTypePrivate
canForkToUser := repository.CanUserForkBetweenOwners(forkRepo.OwnerID, ctx.Doer.ID) && !repo_model.HasForkedRepo(ctx, ctx.Doer.ID, forkRepo.ID)
ctx.Data["ForkRepo"] = forkRepo
ownedOrgs, err := organization.GetOrgsCanCreateRepoByUserID(ctx, ctx.Doer.ID)
if err != nil {
ctx.ServerError("GetOrgsCanCreateRepoByUserID", err)
return nil
}
var orgs []*organization.Organization
for _, org := range ownedOrgs {
if repository.CanUserForkBetweenOwners(forkRepo.OwnerID, org.ID) && !repo_model.HasForkedRepo(ctx, org.ID, forkRepo.ID) {
orgs = append(orgs, org)
}
}
traverseParentRepo := forkRepo
for {
if !repository.CanUserForkBetweenOwners(ctx.Doer.ID, traverseParentRepo.OwnerID) {
canForkToUser = false
}
orgs = slices.DeleteFunc(orgs, func(org *organization.Organization) bool {
return !repository.CanUserForkBetweenOwners(org.ID, traverseParentRepo.OwnerID)
})
if !traverseParentRepo.IsFork {
break
}
traverseParentRepo, err = repo_model.GetRepositoryByID(ctx, traverseParentRepo.ForkID)
if err != nil {
ctx.ServerError("GetRepositoryByID", err)
return nil
}
}
ctx.Data["CanForkToUser"] = canForkToUser
ctx.Data["Orgs"] = orgs
// TODO: this message should only be shown for the "current doer" when it is selected, just like the "new repo" page.
// msg := ctx.TrN(maxCreationLimit, "repo.form.reach_limit_of_creation_1", "repo.form.reach_limit_of_creation_n", ctx.Doer.MaxCreationLimit())
if canForkToUser {
ctx.Data["ContextUser"] = ctx.Doer
ctx.Data["CanForkRepoInNewOwner"] = true
} else if len(orgs) > 0 {
ctx.Data["ContextUser"] = orgs[0]
ctx.Data["CanForkRepoInNewOwner"] = true
} else {
ctx.Data["CanForkRepoInNewOwner"] = false
ctx.Flash.Error(ctx.Tr("repo.fork_no_valid_owners"), true)
return nil
}
branches, err := git_model.FindBranchNames(ctx, git_model.FindBranchOptions{
RepoID: ctx.Repo.Repository.ID,
ListOptions: db.ListOptionsAll,
IsDeletedBranch: optional.Some(false),
// Add it as the first option
ExcludeBranchNames: []string{ctx.Repo.Repository.DefaultBranch},
})
if err != nil {
ctx.ServerError("FindBranchNames", err)
return nil
}
ctx.Data["Branches"] = append([]string{ctx.Repo.Repository.DefaultBranch}, branches...)
return forkRepo
}
// Fork render repository fork page
func Fork(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("new_fork")
getForkRepository(ctx)
if ctx.Written() {
return
}
ctx.HTML(http.StatusOK, tplFork)
}
// ForkPost response for forking a repository
func ForkPost(ctx *context.Context) {
form := web.GetForm[*forms.CreateRepoForm](ctx)
ctx.Data["Title"] = ctx.Tr("new_fork")
ctxUser := checkContextUser(ctx, form.UID)
if ctx.Written() {
return
}
forkRepo := getForkRepository(ctx)
if ctx.Written() {
return
}
ctx.Data["ContextUser"] = ctxUser
if ctx.HasError() {
ctx.JSONError(ctx.GetErrMsg())
return
}
var err error
traverseParentRepo := forkRepo
for {
if !repository.CanUserForkBetweenOwners(ctxUser.ID, traverseParentRepo.OwnerID) {
ctx.JSONError(ctx.Tr("repo.settings.new_owner_has_same_repo"))
return
}
repo := repo_model.GetForkedRepo(ctx, ctxUser.ID, traverseParentRepo.ID)
if repo != nil {
ctx.JSONRedirect(ctxUser.HomeLink() + "/" + url.PathEscape(repo.Name))
return
}
if !traverseParentRepo.IsFork {
break
}
traverseParentRepo, err = repo_model.GetRepositoryByID(ctx, traverseParentRepo.ForkID)
if err != nil {
ctx.ServerError("GetRepositoryByID", err)
return
}
}
// 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 err != nil {
ctx.ServerError("CanCreateOrgRepo", err)
return
} else if !isAllowedToFork {
ctx.HTTPError(http.StatusForbidden)
return
}
}
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
var errNameReserved db.ErrNameReserved
var errNamePatternNotAllowed db.ErrNamePatternNotAllowed
switch {
case repo_model.IsErrReachLimitOfRepo(err):
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):
ctx.JSONError(ctx.Tr("repo.settings.new_owner_has_same_repo"))
case repo_model.IsErrRepoFilesAlreadyExist(err):
switch {
case ctx.IsUserSiteAdmin() || (setting.Repository.AllowAdoptionOfUnadoptedRepositories && setting.Repository.AllowDeleteOfUnadoptedRepositories):
ctx.JSONError(ctx.Tr("form.repository_files_already_exist.adopt_or_delete"))
case setting.Repository.AllowAdoptionOfUnadoptedRepositories:
ctx.JSONError(ctx.Tr("form.repository_files_already_exist.adopt"))
case setting.Repository.AllowDeleteOfUnadoptedRepositories:
ctx.JSONError(ctx.Tr("form.repository_files_already_exist.delete"))
default:
ctx.JSONError(ctx.Tr("form.repository_files_already_exist"))
}
case errors.As(err, &errNameReserved):
ctx.JSONError(ctx.Tr("repo.form.name_reserved", errNameReserved.Name))
case errors.As(err, &errNamePatternNotAllowed):
ctx.JSONError(ctx.Tr("repo.form.name_pattern_not_allowed", errNamePatternNotAllowed.Pattern))
case errors.Is(err, user_model.ErrBlockedUser):
ctx.JSONError(ctx.Tr("repo.fork.blocked_user"))
default:
ctx.ServerError("ForkPost", err)
}
return nil
}
return repo
}