feat(repos): three-level visibility Public/Private/Hidden #428

Merged
jmiller merged 1 commits from dev into main 2026-06-02 18:44:22 +00:00
7 changed files with 99 additions and 55 deletions
+1
View File
@@ -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
}
+20
View File
@@ -0,0 +1,20 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// 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))
}
+1
View File
@@ -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"`
+8
View File
@@ -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.",
+24 -10
View File
@@ -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
}
+8
View File
@@ -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
+37 -45
View File
@@ -880,22 +880,25 @@
<div class="item tw-items-center">
<div class="item-main">
<div class="item-title">{{ctx.Locale.Tr "repo.visibility"}}</div>
{{if .IsSystemRepo}}
<div class="item-body">This is a system repository (dot-prefixed name). System repositories are always private and cannot be made public.</div>
{{else if .Repository.IsPrivate}}
<div class="item-body">{{ctx.Locale.Tr "repo.settings.visibility.public.text"}}</div>
{{else}}
<div class="item-body">{{ctx.Locale.Tr "repo.settings.visibility.private.text"}}</div>
{{end}}
<div class="item-body">
{{if .IsSystemRepo}}
This is a system repository. System repositories are always private.
{{else if .Repository.IsHidden}}
<span class="ui red label">{{ctx.Locale.Tr "repo.settings.visibility.hidden.label"}}</span>
{{ctx.Locale.Tr "repo.settings.visibility.hidden.desc"}}
{{else if .Repository.IsPrivate}}
<span class="ui orange label">{{ctx.Locale.Tr "repo.settings.visibility.private.label"}}</span>
{{ctx.Locale.Tr "repo.settings.visibility.private.desc"}}
{{else}}
<span class="ui green label">{{ctx.Locale.Tr "repo.settings.visibility.public.label"}}</span>
{{ctx.Locale.Tr "repo.settings.visibility.public.desc"}}
{{end}}
</div>
</div>
{{if not .IsSystemRepo}}
<div class="item-trailing">
<button class="ui basic red show-modal button" data-modal="#visibility-repo-modal">
{{if .Repository.IsPrivate}}
{{ctx.Locale.Tr "repo.settings.visibility.public.button"}}
{{else}}
{{ctx.Locale.Tr "repo.settings.visibility.private.button"}}
{{end}}
{{ctx.Locale.Tr "repo.settings.change_visibility"}}
</button>
</div>
{{end}}
@@ -1078,43 +1081,32 @@
{{ctx.Locale.Tr "repo.visibility"}}
</div>
<div class="content">
{{if .Repository.IsPrivate}}
<p>{{ctx.Locale.Tr "repo.settings.visibility.public.bullet_title"}}</p>
<ul>
<li>{{ctx.Locale.Tr "repo.settings.visibility.public.bullet_one"}}</li>
</ul>
{{else}}
<p>{{ctx.Locale.Tr "repo.settings.visibility.private.bullet_title"}}</p>
<ul>
<li>{{ctx.Locale.Tr "repo.settings.visibility.private.bullet_one"}}</li>
<li>
{{ctx.Locale.Tr "repo.settings.visibility.private.bullet_two"}}
</li>
{{if or .Repository.NumStars .Repository.NumWatches .Repository.NumForks}}
<ul class="tw-my-0 tw-pl-4">
{{if .Repository.NumStars}}<li>{{ctx.Locale.Tr "repo.settings.visibility.private.stats_stars" .Repository.NumStars}}</li>{{end}}
{{if .Repository.NumWatches}}<li>{{ctx.Locale.Tr "repo.settings.visibility.private.stats_watchers" .Repository.NumWatches}}</li>{{end}}
{{if .Repository.NumForks}}<li>{{ctx.Locale.Tr "repo.settings.visibility.private.stats_forks" .Repository.NumForks}}</li>{{end}}
</ul>
{{end}}
</ul>
{{end}}
<form class="ui form tw-mt-5 form-fetch-action" action="{{.Link}}" method="post">
<div class="ui warning message">
<p>{{ctx.Locale.Tr "repo.settings.visibility.warning"}}</p>
</div>
<form class="ui form tw-mt-4 form-fetch-action" action="{{.Link}}" method="post">
<input type="hidden" name="action" value="visibility">
<input type="hidden" name="private" value="{{not .Repository.IsPrivate}}">
{{if not .Repository.IsPrivate}}
<div class="grouped fields">
<div class="field">
<label>
{{ctx.Locale.Tr "repo.settings.enter_repo_full_name_to_confirm"}}
<span class="tw-text-red">{{.Repository.FullName}}</span>
</label>
<div class="ui radio checkbox">
<input name="visibility" type="radio" value="public" {{if and (not .Repository.IsPrivate) (not .Repository.IsHidden)}}checked{{end}}>
<label><strong>{{ctx.Locale.Tr "repo.settings.visibility.public.label"}}</strong> — {{ctx.Locale.Tr "repo.settings.visibility.public.desc"}}</label>
</div>
</div>
<div class="required field">
<label>{{ctx.Locale.Tr "repo.repo_name"}}</label>
<input name="confirm_repo_name" required maxlength="200">
<div class="field">
<div class="ui radio checkbox">
<input name="visibility" type="radio" value="private" {{if and .Repository.IsPrivate (not .Repository.IsHidden)}}checked{{end}}>
<label><strong>{{ctx.Locale.Tr "repo.settings.visibility.private.label"}}</strong> — {{ctx.Locale.Tr "repo.settings.visibility.private.desc"}}</label>
</div>
</div>
{{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")))}}
<div class="field">
<div class="ui radio checkbox">
<input name="visibility" type="radio" value="hidden" {{if .Repository.IsHidden}}checked{{end}}>
<label><strong>{{ctx.Locale.Tr "repo.settings.visibility.hidden.label"}}</strong> — {{ctx.Locale.Tr "repo.settings.visibility.hidden.desc"}}</label>
</div>
</div>
</div>
{{template "base/modal_actions_confirm" (dict "ModalButtonDangerText" (ctx.Locale.Tr "repo.settings.change_visibility"))}}
</form>
</div>
</div>