feat(repos): three-level visibility Public/Private/Hidden #428
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user