diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index d781732175..06031df8be 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -419,6 +419,7 @@ func prepareMigrationTasks() []*migration { newMigration(339, "Placeholder for AI tables", noopMigration), newMigration(340, "Sync license system columns (key_raw, payment_ref, heartbeat, archive, metadata)", v1_27.SyncLicenseSystemColumns), newMigration(341, "Add parent_org_id to user table for enterprise sub-org hierarchy", v1_27.AddParentOrgIDToUser), + newMigration(342, "Add is_hidden to repository for three-level visibility", v1_27.AddIsHiddenToRepository), } return preparedMigrations } diff --git a/models/migrations/v1_27/v342.go b/models/migrations/v1_27/v342.go new file mode 100644 index 0000000000..41aaeb8c70 --- /dev/null +++ b/models/migrations/v1_27/v342.go @@ -0,0 +1,20 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package v1_27 + +import "xorm.io/xorm" + +type repoHidden342 struct { + ID int64 `xorm:"pk autoincr"` + IsHidden bool `xorm:"INDEX NOT NULL DEFAULT false"` +} + +func (repoHidden342) TableName() string { + return "repository" +} + +// AddIsHiddenToRepository adds the is_hidden column for three-level repo visibility. +func AddIsHiddenToRepository(x *xorm.Engine) error { + return x.Sync(new(repoHidden342)) +} diff --git a/models/repo/repo.go b/models/repo/repo.go index 23f9d2b501..ab7523e54e 100644 --- a/models/repo/repo.go +++ b/models/repo/repo.go @@ -185,6 +185,7 @@ type Repository struct { NumOpenActionRuns int `xorm:"-"` IsPrivate bool `xorm:"INDEX"` + IsHidden bool `xorm:"INDEX NOT NULL DEFAULT false"` // hidden repos return 404, private repos return 403 IsEmpty bool `xorm:"INDEX"` IsArchived bool `xorm:"INDEX"` IsMirror bool `xorm:"INDEX"` diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 5385b938f5..49bc4e96cd 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -2716,6 +2716,14 @@ "repo.settings.download_gating": "Download Gating", "repo.settings.support_url": "Support / Product Page URL", "repo.settings.support_url_help": "Shown when downloads are gated. Can point to your wiki, product page, or external support site.", + "repo.settings.change_visibility": "Change Visibility", + "repo.settings.visibility.warning": "Changing repository visibility affects who can access code, releases, and update feeds.", + "repo.settings.visibility.public.label": "Public", + "repo.settings.visibility.public.desc": "Visible to everyone. Anyone can clone and view.", + "repo.settings.visibility.private.label": "Private", + "repo.settings.visibility.private.desc": "Members only. Non-members see Access Denied (403). Licensed update feeds still work.", + "repo.settings.visibility.hidden.label": "Hidden", + "repo.settings.visibility.hidden.desc": "Members only. Non-members see Not Found (404). Hides the repo's existence entirely.", "repo.release.update_stream": "Update Stream", "repo.release.update_stream_auto": "(auto-detect from tag name)", "repo.release.update_stream_help": "Assign this release to an update stream. The update feed will serve the latest release per stream.", diff --git a/routers/web/repo/setting/setting.go b/routers/web/repo/setting/setting.go index b2529c2d2d..201daa8e23 100644 --- a/routers/web/repo/setting/setting.go +++ b/routers/web/repo/setting/setting.go @@ -1055,26 +1055,40 @@ func handleSettingsPostVisibility(ctx *context.Context) { return } - private := ctx.FormOptionalBool("private").ValueOrDefault(true) // default to true for privacy & safety + visibility := ctx.FormString("visibility") + // Backward compat: if old "private" field is sent instead of "visibility" + if visibility == "" { + private := ctx.FormOptionalBool("private").ValueOrDefault(true) + if private { + visibility = "private" + } else { + visibility = "public" + } + } - // System repos (dot-prefixed) cannot be made public, regardless of user role. - if !private && repo.IsSystemRepo() { + isPrivate := visibility == "private" || visibility == "hidden" + isHidden := visibility == "hidden" + + if !isPrivate && repo.IsSystemRepo() { ctx.JSONError(ctx.Tr("repo.settings.visibility.system_repo_private")) return } - // when ForcePrivate enabled, you could change public repo to private, but only admin users can change private to public - if !private && setting.Repository.ForcePrivate && !ctx.Doer.IsAdmin { + if !isPrivate && setting.Repository.ForcePrivate && !ctx.Doer.IsAdmin { ctx.JSONError(ctx.Tr("form.repository_force_private")) return } - if private && repo.FullName() != ctx.FormString("confirm_repo_name") { - ctx.JSONError(ctx.Tr("form.enterred_invalid_repo_name")) + + err := repo_service.MakeRepoPrivate(ctx, repo, isPrivate) + if err != nil { + log.Error("Tried to change the visibility of the repo: %s", err) + ctx.JSONError(ctx.Tr("repo.settings.visibility.error")) return } - err := repo_service.MakeRepoPrivate(ctx, repo, private) - if err != nil { - log.Error("Tried to change the visibility of the repo: %s", err) + // Update IsHidden separately. + repo.IsHidden = isHidden + if err := repo_model.UpdateRepositoryCols(ctx, repo, "is_hidden"); err != nil { + log.Error("Failed to update is_hidden: %s", err) ctx.JSONError(ctx.Tr("repo.settings.visibility.error")) return } diff --git a/services/context/repo.go b/services/context/repo.go index cadba73d2c..9bb4240ec2 100644 --- a/services/context/repo.go +++ b/services/context/repo.go @@ -453,10 +453,18 @@ func repoAssignmentLegacy(ctx *Context, data *repoAssignmentPrepareDataStruct) { ctx.Data["HideReleaseDownloads"] = !hasKey && !ctx.IsSigned ctx.Data["LicensedReadOnly"] = true // Continue — don't block access. + } else if repo.IsHidden { + // Hidden repo: 404 — pretend it doesn't exist. + ctx.NotFound(nil) + return } else { + // Private repo: 403 — access denied with styled page. ctx.Forbidden() return } + } else if repo.IsHidden { + ctx.NotFound(nil) + return } else { ctx.Forbidden() return diff --git a/templates/repo/settings/options.tmpl b/templates/repo/settings/options.tmpl index 2feac4d0c5..1a2d96bc41 100644 --- a/templates/repo/settings/options.tmpl +++ b/templates/repo/settings/options.tmpl @@ -880,22 +880,25 @@
{{ctx.Locale.Tr "repo.visibility"}}
- {{if .IsSystemRepo}} -
This is a system repository (dot-prefixed name). System repositories are always private and cannot be made public.
- {{else if .Repository.IsPrivate}} -
{{ctx.Locale.Tr "repo.settings.visibility.public.text"}}
- {{else}} -
{{ctx.Locale.Tr "repo.settings.visibility.private.text"}}
- {{end}} +
+ {{if .IsSystemRepo}} + This is a system repository. System repositories are always private. + {{else if .Repository.IsHidden}} + {{ctx.Locale.Tr "repo.settings.visibility.hidden.label"}} + {{ctx.Locale.Tr "repo.settings.visibility.hidden.desc"}} + {{else if .Repository.IsPrivate}} + {{ctx.Locale.Tr "repo.settings.visibility.private.label"}} + {{ctx.Locale.Tr "repo.settings.visibility.private.desc"}} + {{else}} + {{ctx.Locale.Tr "repo.settings.visibility.public.label"}} + {{ctx.Locale.Tr "repo.settings.visibility.public.desc"}} + {{end}} +
{{if not .IsSystemRepo}}
{{end}} @@ -1078,43 +1081,32 @@ {{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"}}

- - {{end}} -
+
+

{{ctx.Locale.Tr "repo.settings.visibility.warning"}}

+
+ - - {{if not .Repository.IsPrivate}} +
- +
+ + +
-
- - +
+
+ + +
- {{end}} - {{template "base/modal_actions_confirm" (dict "ModalButtonDangerText" (Iif .Repository.IsPrivate (ctx.Locale.Tr "repo.settings.visibility.public.button") (ctx.Locale.Tr "repo.settings.visibility.private.button")))}} +
+
+ + +
+
+
+ {{template "base/modal_actions_confirm" (dict "ModalButtonDangerText" (ctx.Locale.Tr "repo.settings.change_visibility"))}}