Allow instance-wide disabling of forking

For small, personal self-hosted instances with no user signups, the fork
button is just a noise. This patch allows disabling them like stars can
be disabled too.

Disabling forks does not only remove the buttons from the web UI, it
also disables the routes that could be used to create forks.

Fixes #2441.

Signed-off-by: Gergely Nagy <forgejo@gergo.csillger.hu>
This commit is contained in:
Gergely Nagy 2024-02-25 11:58:23 +01:00
parent b7ea2ea463
commit 0ea021c8c9
No known key found for this signature in database
14 changed files with 162 additions and 60 deletions

View file

@ -991,6 +991,9 @@ LEVEL = Info
;; Disable stars feature. ;; Disable stars feature.
;DISABLE_STARS = false ;DISABLE_STARS = false
;; ;;
;; Disable repository forking.
;DISABLE_FORKS = false
;;
;; The default branch name of new repositories ;; The default branch name of new repositories
;DEFAULT_BRANCH = main ;DEFAULT_BRANCH = main
;; ;;

View file

@ -198,6 +198,7 @@ func Contexter() func(next http.Handler) http.Handler {
// FIXME: do we really always need these setting? There should be someway to have to avoid having to always set these // FIXME: do we really always need these setting? There should be someway to have to avoid having to always set these
ctx.Data["DisableMigrations"] = setting.Repository.DisableMigrations ctx.Data["DisableMigrations"] = setting.Repository.DisableMigrations
ctx.Data["DisableStars"] = setting.Repository.DisableStars ctx.Data["DisableStars"] = setting.Repository.DisableStars
ctx.Data["DisableForks"] = setting.Repository.DisableForks
ctx.Data["EnableActions"] = setting.Actions.Enabled ctx.Data["EnableActions"] = setting.Actions.Enabled
ctx.Data["ManifestData"] = setting.ManifestData ctx.Data["ManifestData"] = setting.ManifestData

View file

@ -50,6 +50,7 @@ var (
PrefixArchiveFiles bool PrefixArchiveFiles bool
DisableMigrations bool DisableMigrations bool
DisableStars bool `ini:"DISABLE_STARS"` DisableStars bool `ini:"DISABLE_STARS"`
DisableForks bool
DefaultBranch string DefaultBranch string
AllowAdoptionOfUnadoptedRepositories bool AllowAdoptionOfUnadoptedRepositories bool
AllowDeleteOfUnadoptedRepositories bool AllowDeleteOfUnadoptedRepositories bool
@ -172,6 +173,7 @@ var (
PrefixArchiveFiles: true, PrefixArchiveFiles: true,
DisableMigrations: false, DisableMigrations: false,
DisableStars: false, DisableStars: false,
DisableForks: false,
DefaultBranch: "main", DefaultBranch: "main",
AllowForkWithoutMaximumLimit: true, AllowForkWithoutMaximumLimit: true,

View file

@ -9,6 +9,7 @@ type GeneralRepoSettings struct {
HTTPGitDisabled bool `json:"http_git_disabled"` HTTPGitDisabled bool `json:"http_git_disabled"`
MigrationsDisabled bool `json:"migrations_disabled"` MigrationsDisabled bool `json:"migrations_disabled"`
StarsDisabled bool `json:"stars_disabled"` StarsDisabled bool `json:"stars_disabled"`
ForksDisabled bool `json:"forks_disabled"`
TimeTrackingDisabled bool `json:"time_tracking_disabled"` TimeTrackingDisabled bool `json:"time_tracking_disabled"`
LFSDisabled bool `json:"lfs_disabled"` LFSDisabled bool `json:"lfs_disabled"`
} }

View file

@ -1161,8 +1161,10 @@ func Routes() *web.Route {
m.Get("/raw/*", context.ReferencesGitRepo(), context.RepoRefForAPI, reqRepoReader(unit.TypeCode), repo.GetRawFile) m.Get("/raw/*", context.ReferencesGitRepo(), context.RepoRefForAPI, reqRepoReader(unit.TypeCode), repo.GetRawFile)
m.Get("/media/*", context.ReferencesGitRepo(), context.RepoRefForAPI, reqRepoReader(unit.TypeCode), repo.GetRawFileOrLFS) m.Get("/media/*", context.ReferencesGitRepo(), context.RepoRefForAPI, reqRepoReader(unit.TypeCode), repo.GetRawFileOrLFS)
m.Get("/archive/*", reqRepoReader(unit.TypeCode), repo.GetArchive) m.Get("/archive/*", reqRepoReader(unit.TypeCode), repo.GetArchive)
if !setting.Repository.DisableForks {
m.Combo("/forks").Get(repo.ListForks). m.Combo("/forks").Get(repo.ListForks).
Post(reqToken(), reqRepoReader(unit.TypeCode), bind(api.CreateForkOption{}), repo.CreateFork) Post(reqToken(), reqRepoReader(unit.TypeCode), bind(api.CreateForkOption{}), repo.CreateFork)
}
m.Group("/branches", func() { m.Group("/branches", func() {
m.Get("", repo.ListBranches) m.Get("", repo.ListBranches)
m.Get("/*", repo.GetBranch) m.Get("/*", repo.GetBranch)

View file

@ -61,6 +61,7 @@ func GetGeneralRepoSettings(ctx *context.APIContext) {
HTTPGitDisabled: setting.Repository.DisableHTTPGit, HTTPGitDisabled: setting.Repository.DisableHTTPGit,
MigrationsDisabled: setting.Repository.DisableMigrations, MigrationsDisabled: setting.Repository.DisableMigrations,
StarsDisabled: setting.Repository.DisableStars, StarsDisabled: setting.Repository.DisableStars,
ForksDisabled: setting.Repository.DisableForks,
TimeTrackingDisabled: !setting.Service.EnableTimetracking, TimeTrackingDisabled: !setting.Service.EnableTimetracking,
LFSDisabled: !setting.LFS.StartServer, LFSDisabled: !setting.LFS.StartServer,
}) })

View file

@ -968,7 +968,9 @@ func registerRoutes(m *web.Route) {
m.Post("/create", web.Bind(forms.CreateRepoForm{}), repo.CreatePost) m.Post("/create", web.Bind(forms.CreateRepoForm{}), repo.CreatePost)
m.Get("/migrate", repo.Migrate) m.Get("/migrate", repo.Migrate)
m.Post("/migrate", web.Bind(forms.MigrateRepoForm{}), repo.MigratePost) m.Post("/migrate", web.Bind(forms.MigrateRepoForm{}), repo.MigratePost)
if !setting.Repository.DisableForks {
m.Get("/fork/{repoid}", context.RepoIDAssignment(), context.UnitTypes(), reqRepoCodeReader, repo.ForkByID) m.Get("/fork/{repoid}", context.RepoIDAssignment(), context.UnitTypes(), reqRepoCodeReader, repo.ForkByID)
}
m.Get("/search", repo.SearchRepo) m.Get("/search", repo.SearchRepo)
}, reqSignIn) }, reqSignIn)
@ -1148,8 +1150,10 @@ func registerRoutes(m *web.Route) {
// Grouping for those endpoints that do require authentication // Grouping for those endpoints that do require authentication
m.Group("/{username}/{reponame}", func() { m.Group("/{username}/{reponame}", func() {
if !setting.Repository.DisableForks {
m.Combo("/fork", reqRepoCodeReader).Get(repo.Fork). m.Combo("/fork", reqRepoCodeReader).Get(repo.Fork).
Post(web.Bind(forms.CreateRepoForm{}), repo.ForkPost) Post(web.Bind(forms.CreateRepoForm{}), repo.ForkPost)
}
m.Group("/issues", func() { m.Group("/issues", func() {
m.Group("/new", func() { m.Group("/new", func() {
m.Combo("").Get(context.RepoRef(), repo.NewIssue). m.Combo("").Get(context.RepoRef(), repo.NewIssue).
@ -1560,9 +1564,11 @@ func registerRoutes(m *web.Route) {
m.Get("/*", context.RepoRefByType(context.RepoRefLegacy), repo.Home) m.Get("/*", context.RepoRefByType(context.RepoRefLegacy), repo.Home)
}, repo.SetEditorconfigIfExists) }, repo.SetEditorconfigIfExists)
if !setting.Repository.DisableForks {
m.Group("", func() { m.Group("", func() {
m.Get("/forks", repo.Forks) m.Get("/forks", repo.Forks)
}, context.RepoRef(), reqRepoCodeReader) }, context.RepoRef(), reqRepoCodeReader)
}
m.Get("/commit/{sha:([a-f0-9]{4,64})}.{ext:patch|diff}", repo.MustBeNotEmpty, reqRepoCodeReader, repo.RawDiff) m.Get("/commit/{sha:([a-f0-9]{4,64})}.{ext:patch|diff}", repo.MustBeNotEmpty, reqRepoCodeReader, repo.RawDiff)
}, ignSignIn, context.RepoAssignment, context.UnitTypes()) }, ignSignIn, context.RepoAssignment, context.UnitTypes())

View file

@ -39,7 +39,9 @@
{{if not $.DisableStars}} {{if not $.DisableStars}}
<a class="text grey flex-text-inline" href="{{.Link}}/stars">{{svg "octicon-star" 16}}{{.NumStars}}</a> <a class="text grey flex-text-inline" href="{{.Link}}/stars">{{svg "octicon-star" 16}}{{.NumStars}}</a>
{{end}} {{end}}
{{if not $.DisableForks}}
<a class="text grey flex-text-inline" href="{{.Link}}/forks">{{svg "octicon-git-branch" 16}}{{.NumForks}}</a> <a class="text grey flex-text-inline" href="{{.Link}}/forks">{{svg "octicon-git-branch" 16}}{{.NumForks}}</a>
{{end}}
</div> </div>
</div> </div>
{{$description := .DescriptionHTML $.Context}} {{$description := .DescriptionHTML $.Context}}

View file

@ -29,8 +29,10 @@
<a class="{{if eq .SortType "moststars"}}active {{end}}item" href="{{$.Link}}?tab={{$.TabName}}&sort=moststars&q={{$.Keyword}}&language={{$.Language}}">{{ctx.Locale.Tr "repo.issues.filter_sort.moststars"}}</a> <a class="{{if eq .SortType "moststars"}}active {{end}}item" href="{{$.Link}}?tab={{$.TabName}}&sort=moststars&q={{$.Keyword}}&language={{$.Language}}">{{ctx.Locale.Tr "repo.issues.filter_sort.moststars"}}</a>
<a class="{{if eq .SortType "feweststars"}}active {{end}}item" href="{{$.Link}}?tab={{$.TabName}}&sort=feweststars&q={{$.Keyword}}&language={{$.Language}}">{{ctx.Locale.Tr "repo.issues.filter_sort.feweststars"}}</a> <a class="{{if eq .SortType "feweststars"}}active {{end}}item" href="{{$.Link}}?tab={{$.TabName}}&sort=feweststars&q={{$.Keyword}}&language={{$.Language}}">{{ctx.Locale.Tr "repo.issues.filter_sort.feweststars"}}</a>
{{end}} {{end}}
{{if not .DisableForks}}
<a class="{{if eq .SortType "mostforks"}}active {{end}}item" href="{{$.Link}}?tab={{$.TabName}}&sort=mostforks&q={{$.Keyword}}&language={{$.Language}}">{{ctx.Locale.Tr "repo.issues.filter_sort.mostforks"}}</a> <a class="{{if eq .SortType "mostforks"}}active {{end}}item" href="{{$.Link}}?tab={{$.TabName}}&sort=mostforks&q={{$.Keyword}}&language={{$.Language}}">{{ctx.Locale.Tr "repo.issues.filter_sort.mostforks"}}</a>
<a class="{{if eq .SortType "fewestforks"}}active {{end}}item" href="{{$.Link}}?tab={{$.TabName}}&sort=fewestforks&q={{$.Keyword}}&language={{$.Language}}">{{ctx.Locale.Tr "repo.issues.filter_sort.fewestforks"}}</a> <a class="{{if eq .SortType "fewestforks"}}active {{end}}item" href="{{$.Link}}?tab={{$.TabName}}&sort=fewestforks&q={{$.Keyword}}&language={{$.Language}}">{{ctx.Locale.Tr "repo.issues.filter_sort.fewestforks"}}</a>
{{end}}
</div> </div>
</div> </div>
</div> </div>

View file

@ -62,55 +62,8 @@
{{if not $.DisableStars}} {{if not $.DisableStars}}
{{template "repo/star_unstar" $}} {{template "repo/star_unstar" $}}
{{end}} {{end}}
{{if and (not .IsEmpty) ($.Permission.CanRead $.UnitTypeCode)}} {{if not $.DisableForks}}
<div class="ui labeled button {{template "repo/header_fork" $}}
{{if or (not $.IsSigned) (and (not $.CanSignedUserFork) (not $.UserAndOrgForks))}}
disabled
{{end}}"
{{if not $.IsSigned}}
data-tooltip-content="{{ctx.Locale.Tr "repo.fork_guest_user"}}"
{{else if and (not $.CanSignedUserFork) (not $.UserAndOrgForks)}}
data-tooltip-content="{{ctx.Locale.Tr "repo.fork_from_self"}}"
{{end}}
>
<a class="ui compact{{if $.ShowForkModal}} show-modal{{end}} small basic button"
{{if not $.CanSignedUserFork}}
{{if gt (len $.UserAndOrgForks) 1}}
data-modal="#fork-repo-modal"
{{else if eq (len $.UserAndOrgForks) 1}}
href="{{AppSubUrl}}/{{(index $.UserAndOrgForks 0).FullName}}"
{{/*else is not required here, because the button shouldn't link to any site if you can't create a fork*/}}
{{end}}
{{else if not $.UserAndOrgForks}}
href="{{$.RepoLink}}/fork"
{{else}}
data-modal="#fork-repo-modal"
{{end}}
>
{{svg "octicon-repo-forked"}}<span class="text">{{ctx.Locale.Tr "repo.fork"}}</span>
</a>
<div class="ui small modal" id="fork-repo-modal">
<div class="header">
{{ctx.Locale.Tr "repo.already_forked" .Name}}
</div>
<div class="content gt-text-left">
<div class="ui list">
{{range $.UserAndOrgForks}}
<div class="ui item gt-py-3">
<a href="{{.Link}}">{{svg "octicon-repo-forked" 16 "gt-mr-3"}}{{.FullName}}</a>
</div>
{{end}}
</div>
{{if $.CanSignedUserFork}}
<div class="divider"></div>
<a href="{{$.RepoLink}}/fork">{{ctx.Locale.Tr "repo.fork_to_different_account"}}</a>
{{end}}
</div>
</div>
<a class="ui basic label" href="{{.Link}}/forks">
{{CountFmt .NumForks}}
</a>
</div>
{{end}} {{end}}
</div> </div>
{{end}} {{end}}

View file

@ -0,0 +1,50 @@
{{if and (not .IsEmpty) ($.Permission.CanRead $.UnitTypeCode)}}
<div class="ui labeled button
{{if or (not $.IsSigned) (and (not $.CanSignedUserFork) (not $.UserAndOrgForks))}}
disabled
{{end}}"
{{if not $.IsSigned}}
data-tooltip-content="{{ctx.Locale.Tr "repo.fork_guest_user"}}"
{{else if and (not $.CanSignedUserFork) (not $.UserAndOrgForks)}}
data-tooltip-content="{{ctx.Locale.Tr "repo.fork_from_self"}}"
{{end}}
>
<a class="ui compact{{if $.ShowForkModal}} show-modal{{end}} small basic button"
{{if not $.CanSignedUserFork}}
{{if gt (len $.UserAndOrgForks) 1}}
data-modal="#fork-repo-modal"
{{else if eq (len $.UserAndOrgForks) 1}}
href="{{AppSubUrl}}/{{(index $.UserAndOrgForks 0).FullName}}"
{{/*else is not required here, because the button shouldn't link to any site if you can't create a fork*/}}
{{end}}
{{else if not $.UserAndOrgForks}}
href="{{$.RepoLink}}/fork"
{{else}}
data-modal="#fork-repo-modal"
{{end}}
>
{{svg "octicon-repo-forked"}}<span class="text">{{ctx.Locale.Tr "repo.fork"}}</span>
</a>
<div class="ui small modal" id="fork-repo-modal">
<div class="header">
{{ctx.Locale.Tr "repo.already_forked" .Name}}
</div>
<div class="content gt-text-left">
<div class="ui list">
{{range $.UserAndOrgForks}}
<div class="ui item gt-py-3">
<a href="{{.Link}}">{{svg "octicon-repo-forked" 16 "gt-mr-3"}}{{.FullName}}</a>
</div>
{{end}}
</div>
{{if $.CanSignedUserFork}}
<div class="divider"></div>
<a href="{{$.RepoLink}}/fork">{{ctx.Locale.Tr "repo.fork_to_different_account"}}</a>
{{end}}
</div>
</div>
<a class="ui basic label" href="{{.Link}}/forks">
{{CountFmt .NumForks}}
</a>
</div>
{{end}}

View file

@ -20565,6 +20565,10 @@
"description": "GeneralRepoSettings contains global repository settings exposed by API", "description": "GeneralRepoSettings contains global repository settings exposed by API",
"type": "object", "type": "object",
"properties": { "properties": {
"forks_disabled": {
"type": "boolean",
"x-go-name": "ForksDisabled"
},
"http_git_disabled": { "http_git_disabled": {
"type": "boolean", "type": "boolean",
"x-go-name": "HTTPGitDisabled" "x-go-name": "HTTPGitDisabled"

View file

@ -1,13 +1,18 @@
// Copyright 2017 The Gogs Authors. All rights reserved. // Copyright 2017 The Gogs Authors. All rights reserved.
// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved.
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
package integration package integration
import ( import (
"net/http" "net/http"
"net/url"
"testing" "testing"
"code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs" api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/test"
"code.gitea.io/gitea/routers"
"code.gitea.io/gitea/tests" "code.gitea.io/gitea/tests"
) )
@ -16,3 +21,27 @@ func TestCreateForkNoLogin(t *testing.T) {
req := NewRequestWithJSON(t, "POST", "/api/v1/repos/user2/repo1/forks", &api.CreateForkOption{}) req := NewRequestWithJSON(t, "POST", "/api/v1/repos/user2/repo1/forks", &api.CreateForkOption{})
MakeRequest(t, req, http.StatusUnauthorized) MakeRequest(t, req, http.StatusUnauthorized)
} }
func TestAPIDisabledForkRepo(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
defer test.MockVariableValue(&setting.Repository.DisableForks, true)()
defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
t.Run("fork listing", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/api/v1/repos/user2/repo1/forks")
MakeRequest(t, req, http.StatusNotFound)
})
t.Run("forking", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
session := loginUser(t, "user5")
token := getTokenForLoggedInUser(t, session)
req := NewRequestWithJSON(t, "POST", "/api/v1/repos/user2/repo1/forks", &api.CreateForkOption{}).AddTokenAuth(token)
session.MakeRequest(t, req, http.StatusNotFound)
})
})
}

View file

@ -1,4 +1,5 @@
// Copyright 2017 The Gitea Authors. All rights reserved. // Copyright 2017 The Gitea Authors. All rights reserved.
// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved.
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
package integration package integration
@ -14,6 +15,9 @@ import (
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/test"
"code.gitea.io/gitea/routers"
repo_service "code.gitea.io/gitea/services/repository" repo_service "code.gitea.io/gitea/services/repository"
"code.gitea.io/gitea/tests" "code.gitea.io/gitea/tests"
@ -119,6 +123,48 @@ func TestRepoFork(t *testing.T) {
session.MakeRequest(t, req, http.StatusNotFound) session.MakeRequest(t, req, http.StatusNotFound)
}) })
}) })
t.Run("DISABLE_FORKS", func(t *testing.T) {
defer test.MockVariableValue(&setting.Repository.DisableForks, true)()
defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
t.Run("fork button not present", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
// The "Fork" button should not appear on the repo home
req := NewRequest(t, "GET", "/user2/repo1")
resp := MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
htmlDoc.AssertElement(t, "[href=/user2/repo1/fork]", false)
})
t.Run("forking by URL", func(t *testing.T) {
t.Run("by name", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
// Forking by URL should be Not Found
req := NewRequest(t, "GET", "/user2/repo1/fork")
session.MakeRequest(t, req, http.StatusNotFound)
})
t.Run("by legacy URL", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
// Forking by legacy URL should be Not Found
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) // user2/repo1
req := NewRequestf(t, "GET", "/repo/fork/%d", repo.ID)
session.MakeRequest(t, req, http.StatusNotFound)
})
})
t.Run("fork listing", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
// Listing the forks should be Not Found, too
req := NewRequest(t, "GET", "/user2/repo1/forks")
MakeRequest(t, req, http.StatusNotFound)
})
})
}) })
} }