From d02615bc2b64b99684d4227ce45a87369133dc22 Mon Sep 17 00:00:00 2001 From: logikonline Date: Mon, 26 Jan 2026 21:38:47 -0500 Subject: [PATCH] feat(repo): add limited visibility option for repositories Introduces a new "limited" visibility level between public and private. Limited repos are publicly browseable but restrict clone, fork, and archive downloads to collaborators only. Adds database migration, UI controls in settings, enforcement in git HTTP and SSH handlers, and corresponding locale strings. --- models/migrations/migrations.go | 1 + models/migrations/v1_26/v339.go | 19 +++++++++ models/repo/repo.go | 1 + options/locale/custom_keys.json | 12 +++++- options/locale/locale_en-US.json | 10 +++++ routers/private/serv.go | 22 ++++++++++ routers/web/repo/fork.go | 6 +++ routers/web/repo/githttp.go | 24 ++++++++++- routers/web/repo/repo.go | 12 ++++++ routers/web/repo/setting/setting.go | 24 ++++++----- services/context/repo.go | 22 ++++++++++ services/repository/repository.go | 64 ++++++++++++++++++++++++++++ templates/repo/clone_buttons.tmpl | 2 + templates/repo/clone_panel.tmpl | 7 +++ templates/repo/header.tmpl | 3 ++ templates/repo/settings/options.tmpl | 63 +++++++++++++++++---------- 16 files changed, 256 insertions(+), 36 deletions(-) create mode 100644 models/migrations/v1_26/v339.go diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index cf4b4beccc..0a93c9a9a8 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -413,6 +413,7 @@ func prepareMigrationTasks() []*migration { newMigration(336, "Add external user fields to issue for app integration", v1_26.AddExternalUserFieldsToIssue), newMigration(337, "Add is_private to package for package visibility", v1_26.AddIsPrivateToPackage), newMigration(338, "Add deleted_unix to action_runner table", v1_26.AddDeletedUnixToActionRunner), + newMigration(339, "Add is_limited to repository for limited visibility", v1_26.AddIsLimitedToRepository), } return preparedMigrations } diff --git a/models/migrations/v1_26/v339.go b/models/migrations/v1_26/v339.go new file mode 100644 index 0000000000..18e0091a91 --- /dev/null +++ b/models/migrations/v1_26/v339.go @@ -0,0 +1,19 @@ +// Copyright 2026 MarketAlly. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_26 + +import ( + "xorm.io/xorm" +) + +// AddIsLimitedToRepository adds the is_limited column to the repository table. +// Limited visibility repos are publicly browseable but clone/fork/archive +// are restricted to collaborators only. +func AddIsLimitedToRepository(x *xorm.Engine) error { + type Repository struct { + IsLimited bool `xorm:"INDEX NOT NULL DEFAULT false"` + } + + return x.Sync(new(Repository)) +} diff --git a/models/repo/repo.go b/models/repo/repo.go index 51e2013774..aebb0b0bf4 100644 --- a/models/repo/repo.go +++ b/models/repo/repo.go @@ -188,6 +188,7 @@ type Repository struct { NumOpenActionRuns int `xorm:"-"` IsPrivate bool `xorm:"INDEX"` + IsLimited bool `xorm:"INDEX NOT NULL DEFAULT false"` IsEmpty bool `xorm:"INDEX"` IsArchived bool `xorm:"INDEX"` IsMirror bool `xorm:"INDEX"` diff --git a/options/locale/custom_keys.json b/options/locale/custom_keys.json index b271a16145..afbe29e20c 100644 --- a/options/locale/custom_keys.json +++ b/options/locale/custom_keys.json @@ -808,5 +808,15 @@ "admin.plugins.license_grace": "Grace Period", "admin.plugins.license_invalid": "Invalid", "admin.plugins.license_not_required": "Free", - "admin.plugins.none": "No plugins loaded" + "admin.plugins.none": "No plugins loaded", + "repo.desc.limited": "Limited", + "repo.visibility.public": "Public", + "repo.visibility.public.description": "Anyone can see and clone this repository.", + "repo.visibility.limited": "Limited", + "repo.visibility.limited.description": "Anyone can browse the code, but only collaborators can clone, fork, or download archives.", + "repo.visibility.private": "Private", + "repo.visibility.private.description": "Only collaborators can see or clone this repository.", + "repo.limited_no_clone": "This repository has limited visibility. Clone, fork, and download are restricted to collaborators.", + "repo.settings.visibility.change": "Change Visibility", + "repo.settings.visibility.description": "Control who can see and clone this repository." } \ No newline at end of file diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index c7a9f73f7c..cdbe0a071e 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -1061,6 +1061,7 @@ "repo.transfer.no_permission_to_accept": "You do not have permission to accept this transfer.", "repo.transfer.no_permission_to_reject": "You do not have permission to reject this transfer.", "repo.desc.private": "Private", + "repo.desc.limited": "Limited", "repo.desc.public": "Public", "repo.desc.public_access": "Public Access", "repo.desc.template": "Template", @@ -2494,6 +2495,15 @@ "repo.settings.visibility.success": "Repository visibility changed.", "repo.settings.visibility.error": "An error occurred while trying to change the repo visibility.", "repo.settings.visibility.fork_error": "Can't change the visibility of a forked repo.", + "repo.settings.visibility.change": "Change Visibility", + "repo.settings.visibility.description": "Control who can see and clone this repository.", + "repo.visibility.public": "Public", + "repo.visibility.public.description": "Anyone can see and clone this repository.", + "repo.visibility.limited": "Limited", + "repo.visibility.limited.description": "Anyone can browse the code, but only collaborators can clone, fork, or download archives.", + "repo.visibility.private": "Private", + "repo.visibility.private.description": "Only collaborators can see or clone this repository.", + "repo.limited_no_clone": "This repository has limited visibility. Clone, fork, and download are restricted to collaborators.", "repo.settings.archive.button": "Archive Repo", "repo.settings.archive.header": "Archive This Repo", "repo.settings.archive.text": "Archiving the repo will make it entirely read-only. It will be hidden from the dashboard. Nobody (not even you!) will be able to make new commits, or open any issues or pull requests.", diff --git a/routers/private/serv.go b/routers/private/serv.go index ba6d5383a3..a56ba61ba7 100644 --- a/routers/private/serv.go +++ b/routers/private/serv.go @@ -9,6 +9,7 @@ import ( "strings" asymkey_model "code.gitcaddy.com/server/v3/models/asymkey" + "code.gitcaddy.com/server/v3/models/organization" "code.gitcaddy.com/server/v3/models/perm" access_model "code.gitcaddy.com/server/v3/models/perm/access" repo_model "code.gitcaddy.com/server/v3/models/repo" @@ -359,6 +360,27 @@ func ServCommand(ctx *context.PrivateContext) { } } + // Block clone for limited visibility repos if user is not a collaborator + if repoExist && repo.IsLimited && mode == perm.AccessModeRead && user != nil { + allowed := user.IsAdmin || user.ID == repo.OwnerID + if !allowed { + if isCollab, _ := repo_model.IsCollaborator(ctx, repo.ID, user.ID); isCollab { + allowed = true + } + } + if !allowed && owner.IsOrganization() { + if isMember, _ := organization.IsOrganizationMember(ctx, owner.ID, user.ID); isMember { + allowed = true + } + } + if !allowed { + ctx.JSON(http.StatusForbidden, private.Response{ + UserMsg: "This repository has limited visibility. Clone access requires collaborator permissions.", + }) + return + } + } + // We already know we aren't using a deploy key if !repoExist { owner, err := user_model.GetUserByName(ctx, ownerName) diff --git a/routers/web/repo/fork.go b/routers/web/repo/fork.go index 0c58cee5a6..55f23cfa05 100644 --- a/routers/web/repo/fork.go +++ b/routers/web/repo/fork.go @@ -41,6 +41,12 @@ func getForkRepository(ctx *context.Context) *repo_model.Repository { return nil } + // Block forking for limited visibility repos if user is not a collaborator + if ctx.Data["LimitedNoClone"] == true { + ctx.NotFound(nil) + return nil + } + if err := forkRepo.LoadOwner(ctx); err != nil { ctx.ServerError("LoadOwner", err) return nil diff --git a/routers/web/repo/githttp.go b/routers/web/repo/githttp.go index 82a9a03ba0..502e403e06 100644 --- a/routers/web/repo/githttp.go +++ b/routers/web/repo/githttp.go @@ -19,6 +19,7 @@ import ( "time" auth_model "code.gitcaddy.com/server/v3/models/auth" + "code.gitcaddy.com/server/v3/models/organization" "code.gitcaddy.com/server/v3/models/perm" access_model "code.gitcaddy.com/server/v3/models/perm/access" repo_model "code.gitcaddy.com/server/v3/models/repo" @@ -124,8 +125,8 @@ func httpBase(ctx *context.Context) *serviceHandler { return nil } - // Only public pull don't need auth. - isPublicPull := repoExist && !repo.IsPrivate && isPull + // Only public pull don't need auth. Limited repos require auth for clone. + isPublicPull := repoExist && !repo.IsPrivate && !repo.IsLimited && isPull var ( askAuth = !isPublicPull || setting.Service.RequireSignInViewStrict environ []string @@ -219,6 +220,25 @@ func httpBase(ctx *context.Context) *serviceHandler { } } + // Block clone/pull for limited visibility repos if user is not a collaborator + if repo.IsLimited && isPull && !isWiki { + allowed := ctx.Doer.IsAdmin || ctx.Doer.ID == repo.OwnerID + if !allowed { + if isCollab, _ := repo_model.IsCollaborator(ctx, repo.ID, ctx.Doer.ID); isCollab { + allowed = true + } + } + if !allowed && repo.Owner != nil && repo.Owner.IsOrganization() { + if isMember, _ := organization.IsOrganizationMember(ctx, repo.OwnerID, ctx.Doer.ID); isMember { + allowed = true + } + } + if !allowed { + ctx.PlainText(http.StatusForbidden, "This repository has limited visibility. Clone access requires collaborator permissions.") + return nil + } + } + if !isPull && repo.IsMirror { ctx.PlainText(http.StatusForbidden, "mirror repository is read-only") return nil diff --git a/routers/web/repo/repo.go b/routers/web/repo/repo.go index 55253ba906..29b9ac1eed 100644 --- a/routers/web/repo/repo.go +++ b/routers/web/repo/repo.go @@ -364,6 +364,12 @@ func RedirectDownload(ctx *context.Context) { // Download an archive of a repository func Download(ctx *context.Context) { + // Block archive downloads for limited visibility repos + if ctx.Data["LimitedNoClone"] == true { + ctx.HTTPError(http.StatusForbidden, "This repository has limited visibility. Archive downloads require collaborator permissions.") + return + } + aReq, err := archiver_service.NewRequest(ctx.Repo.Repository, ctx.Repo.GitRepo, ctx.PathParam("*")) if err != nil { if errors.Is(err, archiver_service.ErrUnknownArchiveFormat{}) { @@ -382,6 +388,12 @@ func Download(ctx *context.Context) { // a request that's already in-progress, but the archiver service will just // kind of drop it on the floor if this is the case. func InitiateDownload(ctx *context.Context) { + // Block archive downloads for limited visibility repos + if ctx.Data["LimitedNoClone"] == true { + ctx.HTTPError(http.StatusForbidden, "This repository has limited visibility. Archive downloads require collaborator permissions.") + return + } + if setting.Repository.StreamArchives { ctx.JSON(http.StatusOK, map[string]any{ "complete": true, diff --git a/routers/web/repo/setting/setting.go b/routers/web/repo/setting/setting.go index 3ee0f4adcd..5a02542ebf 100644 --- a/routers/web/repo/setting/setting.go +++ b/routers/web/repo/setting/setting.go @@ -1002,21 +1002,23 @@ func handleSettingsPostVisibility(ctx *context.Context) { return } - var err error + visibility := ctx.FormString("visibility") + if visibility == "" { + // Legacy toggle behavior: flip between public and private + if repo.IsPrivate { + visibility = "public" + } else { + visibility = "private" + } + } - // when ForcePrivate enabled, you could change public repo to private, but only admin users can change private to public - if setting.Repository.ForcePrivate && repo.IsPrivate && !ctx.Doer.IsAdmin { + // when ForcePrivate enabled, only admin users can change private to non-private + if setting.Repository.ForcePrivate && repo.IsPrivate && visibility != "private" && !ctx.Doer.IsAdmin { ctx.RenderWithErr(ctx.Tr("form.repository_force_private"), tplSettingsOptions, form) return } - if repo.IsPrivate { - err = repo_service.MakeRepoPublic(ctx, repo) - } else { - err = repo_service.MakeRepoPrivate(ctx, repo) - } - - if err != nil { + if err := repo_service.UpdateRepoVisibility(ctx, repo, visibility); err != nil { log.Error("Tried to change the visibility of the repo: %s", err) ctx.Flash.Error(ctx.Tr("repo.settings.visibility.error")) ctx.Redirect(ctx.Repo.RepoLink + "/settings") @@ -1025,7 +1027,7 @@ func handleSettingsPostVisibility(ctx *context.Context) { ctx.Flash.Success(ctx.Tr("repo.settings.visibility.success")) - log.Trace("Repository visibility changed: %s/%s", ctx.Repo.Owner.Name, repo.Name) + log.Trace("Repository visibility changed to %s: %s/%s", visibility, ctx.Repo.Owner.Name, repo.Name) ctx.Redirect(ctx.Repo.RepoLink + "/settings") } diff --git a/services/context/repo.go b/services/context/repo.go index 5b87498932..74680bf394 100644 --- a/services/context/repo.go +++ b/services/context/repo.go @@ -598,6 +598,28 @@ func RepoAssignment(ctx *Context) { ctx.Data["CloneButtonShowSSH"] = cloneButtonShowSSH ctx.Data["CloneButtonOriginLink"] = ctx.Data["RepoCloneLink"] // it may be rewritten to the WikiCloneLink by the router middleware + // Limited visibility: repo is browseable but clone/fork/archive blocked for non-collaborators + limitedNoClone := false + if repo.IsLimited && !repo.IsPrivate { + limitedNoClone = true // default: blocked + if ctx.Doer != nil { + if ctx.Doer.IsAdmin || ctx.Doer.ID == repo.OwnerID { + limitedNoClone = false + } else if isCollab, _ := repo_model.IsCollaborator(ctx, repo.ID, ctx.Doer.ID); isCollab { + limitedNoClone = false + } else if repo.Owner != nil && repo.Owner.IsOrganization() { + if isMember, _ := organization.IsOrganizationMember(ctx, repo.OwnerID, ctx.Doer.ID); isMember { + limitedNoClone = false + } + } + } + } + ctx.Data["LimitedNoClone"] = limitedNoClone + if limitedNoClone { + ctx.Data["CanSignedUserFork"] = false + ctx.Data["DisableDownloadSourceArchives"] = true + } + ctx.Data["RepoSearchEnabled"] = setting.Indexer.RepoIndexerEnabled if setting.Indexer.RepoIndexerEnabled { ctx.Data["CodeIndexerUnavailable"] = !code_indexer.IsAvailable(ctx) diff --git a/services/repository/repository.go b/services/repository/repository.go index 572b7433d5..c765b50653 100644 --- a/services/repository/repository.go +++ b/services/repository/repository.go @@ -217,6 +217,70 @@ func MakeRepoPrivate(ctx context.Context, repo *repo_model.Repository) (err erro }) } +// UpdateRepoVisibility sets the repository to public, limited, or private. +// "public": anyone can view and clone +// "limited": anyone can view, but only collaborators can clone/fork/download archives +// "private": only collaborators can view and clone +func UpdateRepoVisibility(ctx context.Context, repo *repo_model.Repository, visibility string) error { + wasPrivate := repo.IsPrivate + + switch visibility { + case "public": + repo.IsPrivate = false + repo.IsLimited = false + case "limited": + repo.IsPrivate = false + repo.IsLimited = true + case "private": + repo.IsLimited = false + // Clear is_limited first, then MakeRepoPrivate handles is_private and cascading effects + if err := repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, "is_limited"); err != nil { + return err + } + return MakeRepoPrivate(ctx, repo) + default: + return fmt.Errorf("invalid visibility: %s", visibility) + } + + // For public/limited: base logic is similar to MakeRepoPublic but also updates is_limited + return db.WithTx(ctx, func(ctx context.Context) error { + if err := repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, "is_private", "is_limited"); err != nil { + return err + } + + if err := repo.LoadOwner(ctx); err != nil { + return fmt.Errorf("LoadOwner: %w", err) + } + if repo.Owner.IsOrganization() { + if err := access_model.RecalculateTeamAccesses(ctx, repo, 0); err != nil { + return fmt.Errorf("recalculateTeamAccesses: %w", err) + } + } + + if err := CheckDaemonExportOK(ctx, repo); err != nil { + return err + } + + // If transitioning from private, update fork visibility + if wasPrivate { + forkRepos, err := repo_model.GetRepositoriesByForkID(ctx, repo.ID) + if err != nil { + return fmt.Errorf("getRepositoriesByForkID: %w", err) + } + if repo.Owner.Visibility != structs.VisibleTypePrivate { + for i := range forkRepos { + if err = MakeRepoPublic(ctx, forkRepos[i]); err != nil { + return fmt.Errorf("MakeRepoPublic[%d]: %w", forkRepos[i].ID, err) + } + } + } + } + + issue_indexer.UpdateRepoIndexer(ctx, repo.ID) + return nil + }) +} + // LinkedRepository returns the linked repo if any func LinkedRepository(ctx context.Context, a *repo_model.Attachment) (*repo_model.Repository, unit.Type, error) { if a.IssueID != 0 { diff --git a/templates/repo/clone_buttons.tmpl b/templates/repo/clone_buttons.tmpl index 03b7a561da..5293350589 100644 --- a/templates/repo/clone_buttons.tmpl +++ b/templates/repo/clone_buttons.tmpl @@ -1,3 +1,4 @@ +{{if not .LimitedNoClone}}
{{if $.CloneButtonShowHTTPS}} @@ -11,3 +12,4 @@ {{svg "octicon-copy" 14}}
+{{end}} diff --git a/templates/repo/clone_panel.tmpl b/templates/repo/clone_panel.tmpl index e23bc8a19a..777e5cce04 100644 --- a/templates/repo/clone_panel.tmpl +++ b/templates/repo/clone_panel.tmpl @@ -1,3 +1,4 @@ +{{if not .LimitedNoClone}} @@ -1006,20 +1007,38 @@ {{ctx.Locale.Tr "repo.visibility"}}
- {{if .Repository.IsPrivate}} -

{{ctx.Locale.Tr "repo.settings.visibility.public.bullet_title"}}

- - {{else}} -

{{ctx.Locale.Tr "repo.settings.visibility.private.bullet_title"}}

- +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+ {{if .Repository.NumForks}} +
+ {{ctx.Locale.Tr "repo.visibility_fork_helper"}} +
{{end}} +
-
+ {{template "base/modal_actions_confirm" .}}