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}}
+{{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}}