feat(packages): add global package access support
Add is_global flag to packages allowing them to be accessible at root URLs without owner prefix. Include database migration, package settings UI, admin bulk operations, and automatic repository matching. This enables cleaner package URLs for organization-wide packages.
This commit is contained in:
@@ -409,6 +409,7 @@ func prepareMigrationTasks() []*migration {
|
||||
newMigration(332, "Add display_title and license_type to repository", v1_26.AddDisplayTitleAndLicenseTypeToRepository),
|
||||
newMigration(333, "Add group_header to repository", v1_26.AddGroupHeaderToRepository),
|
||||
newMigration(334, "Add group_header to user for organization grouping", v1_26.AddGroupHeaderToUser),
|
||||
newMigration(335, "Add is_global to package for global package access", v1_26.AddIsGlobalToPackage),
|
||||
}
|
||||
return preparedMigrations
|
||||
}
|
||||
|
||||
17
models/migrations/v1_26/v335.go
Normal file
17
models/migrations/v1_26/v335.go
Normal file
@@ -0,0 +1,17 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v1_26
|
||||
|
||||
import (
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
// AddIsGlobalToPackage adds is_global column to the package table
|
||||
func AddIsGlobalToPackage(x *xorm.Engine) error {
|
||||
type Package struct {
|
||||
IsGlobal bool `xorm:"NOT NULL DEFAULT false INDEX"`
|
||||
}
|
||||
|
||||
return x.Sync(new(Package))
|
||||
}
|
||||
@@ -191,6 +191,7 @@ type Package struct {
|
||||
LowerName string `xorm:"UNIQUE(s) INDEX NOT NULL"`
|
||||
SemverCompatible bool `xorm:"NOT NULL DEFAULT false"`
|
||||
IsInternal bool `xorm:"NOT NULL DEFAULT false"`
|
||||
IsGlobal bool `xorm:"NOT NULL DEFAULT false INDEX"` // Global packages accessible at root URL
|
||||
}
|
||||
|
||||
// TryInsertPackage inserts a package. If a package exists already, ErrDuplicatePackage is returned
|
||||
@@ -349,3 +350,142 @@ func HasOwnerPackages(ctx context.Context, ownerID int64) (bool, error) {
|
||||
func HasRepositoryPackages(ctx context.Context, repositoryID int64) (bool, error) {
|
||||
return db.GetEngine(ctx).Where("repo_id = ?", repositoryID).Exist(&Package{})
|
||||
}
|
||||
|
||||
// GetGlobalPackageByName gets a global package by type and name (accessible at root URL)
|
||||
func GetGlobalPackageByName(ctx context.Context, packageType Type, name string) (*Package, error) {
|
||||
var cond builder.Cond = builder.Eq{
|
||||
"package.type": packageType,
|
||||
"package.lower_name": strings.ToLower(name),
|
||||
"package.is_internal": false,
|
||||
"package.is_global": true,
|
||||
}
|
||||
|
||||
p := &Package{}
|
||||
|
||||
has, err := db.GetEngine(ctx).
|
||||
Where(cond).
|
||||
Get(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !has {
|
||||
return nil, ErrPackageNotExist
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// ErrGlobalPackageAlreadyExists indicates a global package with the same type+name already exists
|
||||
var ErrGlobalPackageAlreadyExists = util.NewAlreadyExistErrorf("a global package with this type and name already exists")
|
||||
|
||||
// SetPackageIsGlobal sets the global flag on a package with validation
|
||||
func SetPackageIsGlobal(ctx context.Context, packageID int64, isGlobal bool) error {
|
||||
if isGlobal {
|
||||
// Get the package to check its type and name
|
||||
p, err := GetPackageByID(ctx, packageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if another global package with the same type+name exists
|
||||
existing, err := GetGlobalPackageByName(ctx, p.Type, p.Name)
|
||||
if err == nil && existing.ID != packageID {
|
||||
return ErrGlobalPackageAlreadyExists
|
||||
}
|
||||
// ErrPackageNotExist is expected and means we can proceed
|
||||
}
|
||||
|
||||
_, err := db.GetEngine(ctx).ID(packageID).Cols("is_global").Update(&Package{IsGlobal: isGlobal})
|
||||
return err
|
||||
}
|
||||
|
||||
// SetPackagesIsGlobal sets the global flag on multiple packages (for bulk operations)
|
||||
func SetPackagesIsGlobal(ctx context.Context, packageIDs []int64, isGlobal bool) (succeeded, failed int, err error) {
|
||||
for _, id := range packageIDs {
|
||||
if err := SetPackageIsGlobal(ctx, id, isGlobal); err != nil {
|
||||
failed++
|
||||
} else {
|
||||
succeeded++
|
||||
}
|
||||
}
|
||||
return succeeded, failed, nil
|
||||
}
|
||||
|
||||
// FindMatchingRepository tries to find a repository that matches the package name
|
||||
func FindMatchingRepository(ctx context.Context, pkg *Package) (int64, error) {
|
||||
// Try exact match first (case-insensitive)
|
||||
type Repository struct {
|
||||
ID int64
|
||||
LowerName string
|
||||
}
|
||||
|
||||
repo := &Repository{}
|
||||
has, err := db.GetEngine(ctx).
|
||||
Table("repository").
|
||||
Where("owner_id = ? AND lower_name = ?", pkg.OwnerID, strings.ToLower(pkg.Name)).
|
||||
Get(repo)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if has {
|
||||
return repo.ID, nil
|
||||
}
|
||||
|
||||
// Try partial match - package name contains repo name or vice versa
|
||||
repos := make([]*Repository, 0)
|
||||
err = db.GetEngine(ctx).
|
||||
Table("repository").
|
||||
Where("owner_id = ?", pkg.OwnerID).
|
||||
Find(&repos)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
pkgLower := strings.ToLower(pkg.Name)
|
||||
for _, r := range repos {
|
||||
if strings.Contains(pkgLower, r.LowerName) || strings.Contains(r.LowerName, pkgLower) {
|
||||
return r.ID, nil
|
||||
}
|
||||
}
|
||||
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// AutoMatchPackageToRepository automatically links a package to a matching repository
|
||||
func AutoMatchPackageToRepository(ctx context.Context, packageID int64) (repoID int64, err error) {
|
||||
pkg, err := GetPackageByID(ctx, packageID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// Already linked
|
||||
if pkg.RepoID > 0 {
|
||||
return pkg.RepoID, nil
|
||||
}
|
||||
|
||||
repoID, err = FindMatchingRepository(ctx, pkg)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if repoID > 0 {
|
||||
err = SetRepositoryLink(ctx, packageID, repoID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
|
||||
return repoID, nil
|
||||
}
|
||||
|
||||
// GetAllGlobalPackages gets all packages marked as global
|
||||
func GetAllGlobalPackages(ctx context.Context) ([]*Package, error) {
|
||||
var cond builder.Cond = builder.Eq{
|
||||
"package.is_internal": false,
|
||||
"package.is_global": true,
|
||||
}
|
||||
|
||||
ps := make([]*Package, 0, 10)
|
||||
return ps, db.GetEngine(ctx).
|
||||
Where(cond).
|
||||
Find(&ps)
|
||||
}
|
||||
|
||||
@@ -3127,6 +3127,26 @@
|
||||
"admin.packages.repository": "Repository",
|
||||
"admin.packages.size": "Size",
|
||||
"admin.packages.published": "Published",
|
||||
"admin.packages.global": "Global",
|
||||
"admin.packages.global.yes": "Global",
|
||||
"admin.packages.global.no": "No",
|
||||
"admin.packages.bulk.actions": "Bulk Actions",
|
||||
"admin.packages.bulk.enable_global": "Enable Global Access",
|
||||
"admin.packages.bulk.disable_global": "Disable Global Access",
|
||||
"admin.packages.bulk.automatch": "Auto-Match to Repositories",
|
||||
"admin.packages.bulk.selected": "Selected:",
|
||||
"admin.packages.bulk.no_selection": "Please select at least one package",
|
||||
"admin.packages.bulk.global.enabled": "Enabled global access for %d package(s)",
|
||||
"admin.packages.bulk.global.disabled": "Disabled global access for %d package(s)",
|
||||
"admin.packages.bulk.global.partial": "Enabled global access for %d package(s), %d failed (may already exist as global)",
|
||||
"admin.packages.bulk.automatch.success": "Auto-matched %d package(s) to repositories",
|
||||
"admin.packages.bulk.automatch.none": "No matching repositories found for selected packages",
|
||||
"admin.packages.automatch.button": "Find matching repository",
|
||||
"admin.packages.automatch.match": "Match",
|
||||
"admin.packages.automatch.success": "Package linked to matching repository",
|
||||
"admin.packages.automatch.no_match": "No matching repository found",
|
||||
"admin.packages.automatch.error": "Error matching package to repository",
|
||||
"admin.packages.automatch.invalid": "Invalid package ID",
|
||||
"admin.defaulthooks": "Default Webhooks",
|
||||
"admin.defaulthooks.desc": "Webhooks automatically make HTTP POST requests to a server when certain GitCaddy events trigger. Webhooks defined here are defaults and will be copied into all new repositories. Read more in the <a target=\"_blank\" rel=\"noopener\" href=\"%s\">webhooks guide</a>.",
|
||||
"admin.defaulthooks.add_webhook": "Add Default Webhook",
|
||||
@@ -3649,6 +3669,13 @@
|
||||
"packages.settings.delete.notice": "You are about to delete %s (%s). This operation is irreversible, are you sure?",
|
||||
"packages.settings.delete.success": "The package has been deleted.",
|
||||
"packages.settings.delete.error": "Failed to delete the package.",
|
||||
"packages.settings.global_access": "Global Access",
|
||||
"packages.settings.global_access.description": "Global packages can be accessed at the root URL without specifying an owner (e.g., /api/packages/_/npm/...).",
|
||||
"packages.settings.global_access.enable": "Make this package globally accessible",
|
||||
"packages.settings.global_access.help": "When enabled, this package can be accessed via /api/packages/_/{type}/{name} in addition to the owner-specific URL.",
|
||||
"packages.settings.global_access.enabled": "Package is now globally accessible.",
|
||||
"packages.settings.global_access.disabled": "Package global access has been disabled.",
|
||||
"packages.settings.global_access.error": "Failed to update global access setting.",
|
||||
"packages.owner.settings.cargo.title": "Cargo Registry Index",
|
||||
"packages.owner.settings.cargo.initialize": "Initialize Index",
|
||||
"packages.owner.settings.cargo.initialize.description": "A special index Git repository is needed to use the Cargo registry. Using this option will (re-)create the repository and configure it automatically.",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
@@ -106,3 +107,97 @@ func CleanupExpiredData(ctx *context.Context) {
|
||||
ctx.Flash.Success(ctx.Tr("admin.packages.cleanup.success"))
|
||||
ctx.Redirect(setting.AppSubURL + "/-/admin/packages")
|
||||
}
|
||||
|
||||
// BulkSetGlobal sets/unsets global flag on multiple packages
|
||||
func BulkSetGlobal(ctx *context.Context) {
|
||||
packageIDs := ctx.FormStrings("ids[]")
|
||||
isGlobal := ctx.FormBool("is_global")
|
||||
|
||||
ids := make([]int64, 0, len(packageIDs))
|
||||
for _, idStr := range packageIDs {
|
||||
var id int64
|
||||
if _, err := fmt.Sscanf(idStr, "%d", &id); err == nil && id > 0 {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
|
||||
if len(ids) == 0 {
|
||||
ctx.Flash.Error(ctx.Tr("admin.packages.bulk.no_selection"))
|
||||
ctx.JSONRedirect(setting.AppSubURL + "/-/admin/packages")
|
||||
return
|
||||
}
|
||||
|
||||
succeeded, failed, _ := packages_model.SetPackagesIsGlobal(ctx, ids, isGlobal)
|
||||
|
||||
if failed > 0 {
|
||||
ctx.Flash.Warning(ctx.Tr("admin.packages.bulk.global.partial", succeeded, failed))
|
||||
} else {
|
||||
if isGlobal {
|
||||
ctx.Flash.Success(ctx.Tr("admin.packages.bulk.global.enabled", succeeded))
|
||||
} else {
|
||||
ctx.Flash.Success(ctx.Tr("admin.packages.bulk.global.disabled", succeeded))
|
||||
}
|
||||
}
|
||||
|
||||
ctx.JSONRedirect(setting.AppSubURL + "/-/admin/packages")
|
||||
}
|
||||
|
||||
// BulkAutoMatch automatically matches packages to repositories
|
||||
func BulkAutoMatch(ctx *context.Context) {
|
||||
packageIDs := ctx.FormStrings("ids[]")
|
||||
|
||||
ids := make([]int64, 0, len(packageIDs))
|
||||
for _, idStr := range packageIDs {
|
||||
var id int64
|
||||
if _, err := fmt.Sscanf(idStr, "%d", &id); err == nil && id > 0 {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
|
||||
if len(ids) == 0 {
|
||||
ctx.Flash.Error(ctx.Tr("admin.packages.bulk.no_selection"))
|
||||
ctx.JSONRedirect(setting.AppSubURL + "/-/admin/packages")
|
||||
return
|
||||
}
|
||||
|
||||
matched := 0
|
||||
for _, id := range ids {
|
||||
repoID, err := packages_model.AutoMatchPackageToRepository(ctx, id)
|
||||
if err == nil && repoID > 0 {
|
||||
matched++
|
||||
}
|
||||
}
|
||||
|
||||
if matched > 0 {
|
||||
ctx.Flash.Success(ctx.Tr("admin.packages.bulk.automatch.success", matched))
|
||||
} else {
|
||||
ctx.Flash.Warning(ctx.Tr("admin.packages.bulk.automatch.none"))
|
||||
}
|
||||
|
||||
ctx.JSONRedirect(setting.AppSubURL + "/-/admin/packages")
|
||||
}
|
||||
|
||||
// SingleAutoMatch automatically matches a single package to a repository
|
||||
func SingleAutoMatch(ctx *context.Context) {
|
||||
packageID := ctx.FormInt64("id")
|
||||
if packageID == 0 {
|
||||
ctx.Flash.Error(ctx.Tr("admin.packages.automatch.invalid"))
|
||||
ctx.JSONRedirect(setting.AppSubURL + "/-/admin/packages")
|
||||
return
|
||||
}
|
||||
|
||||
repoID, err := packages_model.AutoMatchPackageToRepository(ctx, packageID)
|
||||
if err != nil {
|
||||
ctx.Flash.Error(ctx.Tr("admin.packages.automatch.error"))
|
||||
ctx.JSONRedirect(setting.AppSubURL + "/-/admin/packages")
|
||||
return
|
||||
}
|
||||
|
||||
if repoID > 0 {
|
||||
ctx.Flash.Success(ctx.Tr("admin.packages.automatch.success"))
|
||||
} else {
|
||||
ctx.Flash.Warning(ctx.Tr("admin.packages.automatch.no_match"))
|
||||
}
|
||||
|
||||
ctx.JSONRedirect(setting.AppSubURL + "/-/admin/packages")
|
||||
}
|
||||
|
||||
@@ -235,11 +235,14 @@ func home(ctx *context.Context, viewRepositories bool) {
|
||||
|
||||
// Always show overview by default for organizations
|
||||
isViewOverview := !viewRepositories
|
||||
// Load profile readme if available
|
||||
prepareOrgProfileReadme(ctx, prepareResult)
|
||||
ctx.Data["PageIsViewRepositories"] = !isViewOverview
|
||||
ctx.Data["PageIsViewOverview"] = isViewOverview
|
||||
ctx.Data["ShowOrgProfileReadmeSelector"] = isViewOverview && prepareResult.ProfilePublicReadmeBlob != nil && prepareResult.ProfilePrivateReadmeBlob != nil
|
||||
|
||||
// Load profile readme only on overview page (not repositories page)
|
||||
if isViewOverview {
|
||||
prepareOrgProfileReadme(ctx, prepareResult)
|
||||
ctx.Data["ShowOrgProfileReadmeSelector"] = prepareResult.ProfilePublicReadmeBlob != nil && prepareResult.ProfilePrivateReadmeBlob != nil
|
||||
}
|
||||
|
||||
// When grouping is enabled, order by group_header first to keep groups together
|
||||
finalOrderBy := orderBy
|
||||
|
||||
@@ -453,6 +453,8 @@ func PackageSettingsPost(ctx *context.Context) {
|
||||
packageSettingsPostActionLink(ctx, form)
|
||||
case "delete":
|
||||
packageSettingsPostActionDelete(ctx)
|
||||
case "global":
|
||||
packageSettingsPostActionGlobal(ctx)
|
||||
default:
|
||||
ctx.NotFound(nil)
|
||||
}
|
||||
@@ -508,6 +510,30 @@ func packageSettingsPostActionDelete(ctx *context.Context) {
|
||||
ctx.Redirect(redirectURL)
|
||||
}
|
||||
|
||||
func packageSettingsPostActionGlobal(ctx *context.Context) {
|
||||
// Only admins can set global flag
|
||||
if !ctx.IsUserSiteAdmin() {
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
}
|
||||
|
||||
pd := ctx.Package.Descriptor
|
||||
isGlobal := ctx.FormBool("is_global")
|
||||
|
||||
if err := packages_model.SetPackageIsGlobal(ctx, pd.Package.ID, isGlobal); err != nil {
|
||||
ctx.Flash.Error(ctx.Tr("packages.settings.global_access.error"))
|
||||
ctx.Redirect(ctx.Package.Descriptor.VersionWebLink() + "/settings")
|
||||
return
|
||||
}
|
||||
|
||||
if isGlobal {
|
||||
ctx.Flash.Success(ctx.Tr("packages.settings.global_access.enabled"))
|
||||
} else {
|
||||
ctx.Flash.Success(ctx.Tr("packages.settings.global_access.disabled"))
|
||||
}
|
||||
ctx.Redirect(ctx.Package.Descriptor.VersionWebLink() + "/settings")
|
||||
}
|
||||
|
||||
// DownloadPackageFile serves the content of a package file
|
||||
func DownloadPackageFile(ctx *context.Context) {
|
||||
pf, err := packages_model.GetFileForVersionByID(ctx, ctx.Package.Descriptor.Version.ID, ctx.PathParamInt64("fileid"))
|
||||
|
||||
@@ -831,6 +831,9 @@ func registerWebRoutes(m *web.Router) {
|
||||
m.Get("", admin.Packages)
|
||||
m.Post("/delete", admin.DeletePackageVersion)
|
||||
m.Post("/cleanup", admin.CleanupExpiredData)
|
||||
m.Post("/bulk-global", admin.BulkSetGlobal)
|
||||
m.Post("/bulk-automatch", admin.BulkAutoMatch)
|
||||
m.Post("/automatch", admin.SingleAutoMatch)
|
||||
}, packagesEnabled)
|
||||
|
||||
m.Group("/hooks", func() {
|
||||
|
||||
@@ -61,6 +61,10 @@ func packageAssignment(ctx *packageAssignmentCtx, errCb func(int, any)) *Package
|
||||
pkg := &Package{
|
||||
Owner: ctx.ContextUser,
|
||||
}
|
||||
|
||||
// Handle global packages (when owner is nil, i.e., username was "_")
|
||||
isGlobalRequest := ctx.ContextUser == nil
|
||||
|
||||
var err error
|
||||
pkg.AccessMode, err = determineAccessMode(ctx.Base, pkg, ctx.Doer)
|
||||
if err != nil {
|
||||
@@ -72,14 +76,47 @@ func packageAssignment(ctx *packageAssignmentCtx, errCb func(int, any)) *Package
|
||||
name := ctx.PathParam("name")
|
||||
version := ctx.PathParam("version")
|
||||
if packageType != "" && name != "" && version != "" {
|
||||
pv, err := packages_model.GetVersionByNameAndVersion(ctx, pkg.Owner.ID, packages_model.Type(packageType), name, version)
|
||||
if err != nil {
|
||||
if err == packages_model.ErrPackageNotExist {
|
||||
errCb(http.StatusNotFound, fmt.Errorf("GetVersionByNameAndVersion: %w", err))
|
||||
} else {
|
||||
errCb(http.StatusInternalServerError, fmt.Errorf("GetVersionByNameAndVersion: %w", err))
|
||||
var pv *packages_model.PackageVersion
|
||||
|
||||
if isGlobalRequest {
|
||||
// Look up global package by name (no owner)
|
||||
p, err := packages_model.GetGlobalPackageByName(ctx, packages_model.Type(packageType), name)
|
||||
if err != nil {
|
||||
if err == packages_model.ErrPackageNotExist {
|
||||
errCb(http.StatusNotFound, fmt.Errorf("GetGlobalPackageByName: %w", err))
|
||||
} else {
|
||||
errCb(http.StatusInternalServerError, fmt.Errorf("GetGlobalPackageByName: %w", err))
|
||||
}
|
||||
return pkg
|
||||
}
|
||||
// Get the actual owner for the package
|
||||
owner, err := user_model.GetUserByID(ctx, p.OwnerID)
|
||||
if err != nil {
|
||||
errCb(http.StatusInternalServerError, fmt.Errorf("GetUserByID: %w", err))
|
||||
return pkg
|
||||
}
|
||||
pkg.Owner = owner
|
||||
|
||||
// Get the version
|
||||
pv, err = packages_model.GetVersionByNameAndVersion(ctx, p.OwnerID, packages_model.Type(packageType), name, version)
|
||||
if err != nil {
|
||||
if err == packages_model.ErrPackageNotExist {
|
||||
errCb(http.StatusNotFound, fmt.Errorf("GetVersionByNameAndVersion: %w", err))
|
||||
} else {
|
||||
errCb(http.StatusInternalServerError, fmt.Errorf("GetVersionByNameAndVersion: %w", err))
|
||||
}
|
||||
return pkg
|
||||
}
|
||||
} else {
|
||||
pv, err = packages_model.GetVersionByNameAndVersion(ctx, pkg.Owner.ID, packages_model.Type(packageType), name, version)
|
||||
if err != nil {
|
||||
if err == packages_model.ErrPackageNotExist {
|
||||
errCb(http.StatusNotFound, fmt.Errorf("GetVersionByNameAndVersion: %w", err))
|
||||
} else {
|
||||
errCb(http.StatusInternalServerError, fmt.Errorf("GetVersionByNameAndVersion: %w", err))
|
||||
}
|
||||
return pkg
|
||||
}
|
||||
return pkg
|
||||
}
|
||||
|
||||
pkg.Descriptor, err = packages_model.GetPackageDescriptor(ctx, pv)
|
||||
@@ -101,6 +138,14 @@ func determineAccessMode(ctx *Base, pkg *Package, doer *user_model.User) (perm.A
|
||||
return perm.AccessModeNone, nil
|
||||
}
|
||||
|
||||
// Global packages (no owner) - allow read access to everyone, write only to admins
|
||||
if pkg.Owner == nil {
|
||||
if doer != nil && doer.IsAdmin {
|
||||
return perm.AccessModeAdmin, nil
|
||||
}
|
||||
return perm.AccessModeRead, nil
|
||||
}
|
||||
|
||||
// TODO: ActionUser permission check
|
||||
accessMode := perm.AccessModeNone
|
||||
if pkg.Owner.IsOrganization() {
|
||||
|
||||
@@ -61,6 +61,11 @@ func UserAssignmentAPI() func(ctx *APIContext) {
|
||||
func userAssignment(ctx *Base, doer *user_model.User, errCb func(int, any)) (contextUser *user_model.User) {
|
||||
username := ctx.PathParam("username")
|
||||
|
||||
// Special case: "_" means global packages (no specific owner)
|
||||
if username == "_" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if doer != nil && strings.EqualFold(doer.LowerName, username) {
|
||||
contextUser = doer
|
||||
} else {
|
||||
|
||||
@@ -25,10 +25,26 @@
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="ui attached segment">
|
||||
<div class="tw-flex tw-gap-2">
|
||||
<div class="ui small dropdown button" id="bulk-actions-dropdown">
|
||||
<span class="text">{{ctx.Locale.Tr "admin.packages.bulk.actions"}}</span>
|
||||
{{svg "octicon-triangle-down" 14}}
|
||||
<div class="menu">
|
||||
<div class="item" data-action="enable-global">{{svg "octicon-globe" 14}} {{ctx.Locale.Tr "admin.packages.bulk.enable_global"}}</div>
|
||||
<div class="item" data-action="disable-global">{{svg "octicon-lock" 14}} {{ctx.Locale.Tr "admin.packages.bulk.disable_global"}}</div>
|
||||
<div class="divider"></div>
|
||||
<div class="item" data-action="automatch">{{svg "octicon-link" 14}} {{ctx.Locale.Tr "admin.packages.bulk.automatch"}}</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="tw-text-text-light-2 tw-self-center" id="selected-count"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui attached table segment">
|
||||
<table class="ui very basic striped table unstackable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="tw-w-8"><input type="checkbox" id="select-all-packages"></th>
|
||||
<th>ID</th>
|
||||
<th>{{ctx.Locale.Tr "admin.packages.owner"}}</th>
|
||||
<th>{{ctx.Locale.Tr "admin.packages.type"}}</th>
|
||||
@@ -42,6 +58,7 @@
|
||||
</th>
|
||||
<th>{{ctx.Locale.Tr "admin.packages.creator"}}</th>
|
||||
<th>{{ctx.Locale.Tr "admin.packages.repository"}}</th>
|
||||
<th>{{ctx.Locale.Tr "admin.packages.global"}}</th>
|
||||
<th>{{ctx.Locale.Tr "admin.packages.size"}}</th>
|
||||
<th data-sortt-asc="created_asc" data-sortt-desc="created_desc">
|
||||
{{ctx.Locale.Tr "admin.packages.published"}}
|
||||
@@ -53,6 +70,7 @@
|
||||
<tbody>
|
||||
{{range .PackageDescriptors}}
|
||||
<tr>
|
||||
<td><input type="checkbox" class="package-checkbox" data-package-id="{{.Package.ID}}"></td>
|
||||
<td>{{.Version.ID}}</td>
|
||||
<td>
|
||||
<a href="{{.Owner.HomeLink}}">{{.Owner.Name}}</a>
|
||||
@@ -67,8 +85,19 @@
|
||||
<td>
|
||||
{{if .Repository}}
|
||||
<a href="{{.Repository.Link}}">{{.Repository.Name}}</a>
|
||||
{{else}}
|
||||
<button class="ui tiny basic button automatch-btn" data-package-id="{{.Package.ID}}" title="{{ctx.Locale.Tr "admin.packages.automatch.button"}}">
|
||||
{{svg "octicon-link" 12}} {{ctx.Locale.Tr "admin.packages.automatch.match"}}
|
||||
</button>
|
||||
{{end}}
|
||||
</td>
|
||||
<td>
|
||||
{{if .Package.IsGlobal}}
|
||||
<span class="ui tiny green label">{{svg "octicon-globe" 12}} {{ctx.Locale.Tr "admin.packages.global.yes"}}</span>
|
||||
{{else}}
|
||||
<span class="text grey">{{ctx.Locale.Tr "admin.packages.global.no"}}</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td>{{FileSize .CalculateBlobSize}}</td>
|
||||
<td>{{DateUtils.AbsoluteShort .Version.CreatedUnix}}</td>
|
||||
<td>
|
||||
@@ -79,7 +108,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td class="tw-text-center" colspan="10">{{ctx.Locale.Tr "no_results_found"}}</td></tr>
|
||||
<tr><td class="tw-text-center" colspan="12">{{ctx.Locale.Tr "no_results_found"}}</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -96,4 +125,103 @@
|
||||
{{template "base/modal_actions_confirm" .}}
|
||||
</form>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const selectAllCheckbox = document.getElementById('select-all-packages');
|
||||
const packageCheckboxes = document.querySelectorAll('.package-checkbox');
|
||||
const selectedCountEl = document.getElementById('selected-count');
|
||||
const bulkDropdown = document.getElementById('bulk-actions-dropdown');
|
||||
|
||||
function updateSelectedCount() {
|
||||
const selected = document.querySelectorAll('.package-checkbox:checked').length;
|
||||
if (selected > 0) {
|
||||
selectedCountEl.textContent = '{{ctx.Locale.Tr "admin.packages.bulk.selected"}} ' + selected;
|
||||
} else {
|
||||
selectedCountEl.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
selectAllCheckbox?.addEventListener('change', function() {
|
||||
packageCheckboxes.forEach(cb => cb.checked = this.checked);
|
||||
updateSelectedCount();
|
||||
});
|
||||
|
||||
packageCheckboxes.forEach(cb => {
|
||||
cb.addEventListener('change', updateSelectedCount);
|
||||
});
|
||||
|
||||
function getSelectedIds() {
|
||||
return Array.from(document.querySelectorAll('.package-checkbox:checked'))
|
||||
.map(cb => cb.dataset.packageId);
|
||||
}
|
||||
|
||||
bulkDropdown?.querySelectorAll('.item').forEach(item => {
|
||||
item.addEventListener('click', function() {
|
||||
const action = this.dataset.action;
|
||||
const ids = getSelectedIds();
|
||||
if (ids.length === 0) {
|
||||
alert('{{ctx.Locale.Tr "admin.packages.bulk.no_selection"}}');
|
||||
return;
|
||||
}
|
||||
|
||||
let url, formData;
|
||||
formData = new FormData();
|
||||
ids.forEach(id => formData.append('ids[]', id));
|
||||
|
||||
switch(action) {
|
||||
case 'enable-global':
|
||||
url = '{{AppSubUrl}}/-/admin/packages/bulk-global';
|
||||
formData.append('is_global', 'true');
|
||||
break;
|
||||
case 'disable-global':
|
||||
url = '{{AppSubUrl}}/-/admin/packages/bulk-global';
|
||||
formData.append('is_global', 'false');
|
||||
break;
|
||||
case 'automatch':
|
||||
url = '{{AppSubUrl}}/-/admin/packages/bulk-automatch';
|
||||
break;
|
||||
}
|
||||
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'X-Csrf-Token': document.querySelector('meta[name=_csrf]')?.content || ''
|
||||
}
|
||||
}).then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.redirect) {
|
||||
window.location.href = data.redirect;
|
||||
} else {
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.automatch-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const packageId = this.dataset.packageId;
|
||||
const formData = new FormData();
|
||||
formData.append('id', packageId);
|
||||
|
||||
fetch('{{AppSubUrl}}/-/admin/packages/automatch', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'X-Csrf-Token': document.querySelector('meta[name=_csrf]')?.content || ''
|
||||
}
|
||||
}).then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.redirect) {
|
||||
window.location.href = data.redirect;
|
||||
} else {
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{{template "admin/layout_footer" .}}
|
||||
|
||||
@@ -26,6 +26,25 @@
|
||||
<button class="ui primary button">{{ctx.Locale.Tr "packages.settings.link.button"}}</button>
|
||||
</form>
|
||||
</div>
|
||||
{{if .IsAdmin}}
|
||||
<h4 class="ui top attached header">
|
||||
{{ctx.Locale.Tr "packages.settings.global_access"}}
|
||||
</h4>
|
||||
<div class="ui attached segment">
|
||||
<p>{{ctx.Locale.Tr "packages.settings.global_access.description"}}</p>
|
||||
<form class="ui form" action="{{.Link}}" method="post">
|
||||
<input type="hidden" name="action" value="global">
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="is_global" {{if .PackageDescriptor.Package.IsGlobal}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "packages.settings.global_access.enable"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
<p class="help">{{ctx.Locale.Tr "packages.settings.global_access.help"}}</p>
|
||||
<button class="ui primary button">{{ctx.Locale.Tr "save"}}</button>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
<h4 class="ui top attached error header">
|
||||
{{ctx.Locale.Tr "repo.settings.danger_zone"}}
|
||||
</h4>
|
||||
|
||||
Reference in New Issue
Block a user