Move package settings to package instead of being tied to version (#37026)

Unties settings page from package version and adds button to delete the
package version
Settings page now allows for deletion of entire package and it's
versions as opposed to a single version

Adds an API endpoint to delete the entire package with all versions from
registry

fixes: https://github.com/go-gitea/gitea/issues/36904

Co-Authored-By: gemini-3-flash

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: Giteabot <teabot@gitea.io>
This commit is contained in:
TheFox0x7
2026-04-05 21:51:51 +02:00
committed by GitHub
parent a8938115d4
commit ca51b4f875
19 changed files with 477 additions and 45 deletions
+20 -2
View File
@@ -9,6 +9,7 @@ import (
"fmt" "fmt"
"net/url" "net/url"
"code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/cache" "code.gitea.io/gitea/modules/cache"
@@ -53,8 +54,11 @@ func (l PackagePropertyList) GetByName(name string) string {
// PackageDescriptor describes a package // PackageDescriptor describes a package
type PackageDescriptor struct { type PackageDescriptor struct {
Package *Package // basic package info
Owner *user_model.User Package *Package
Owner *user_model.User
// package version info
Repository *repo_model.Repository Repository *repo_model.Repository
Version *PackageVersion Version *PackageVersion
SemVer *version.Version SemVer *version.Version
@@ -77,6 +81,11 @@ func (pd *PackageDescriptor) PackageWebLink() string {
return fmt.Sprintf("%s/-/packages/%s/%s", pd.Owner.HomeLink(), string(pd.Package.Type), url.PathEscape(pd.Package.LowerName)) return fmt.Sprintf("%s/-/packages/%s/%s", pd.Owner.HomeLink(), string(pd.Package.Type), url.PathEscape(pd.Package.LowerName))
} }
// PackageSettingsLink returns the relative package settings link
func (pd *PackageDescriptor) PackageSettingsLink() string {
return fmt.Sprintf("%s/-/packages/settings/%s/%s", pd.Owner.HomeLink(), string(pd.Package.Type), url.PathEscape(pd.Package.LowerName))
}
// VersionWebLink returns the relative package version web link // VersionWebLink returns the relative package version web link
func (pd *PackageDescriptor) VersionWebLink() string { func (pd *PackageDescriptor) VersionWebLink() string {
return fmt.Sprintf("%s/%s", pd.PackageWebLink(), url.PathEscape(pd.Version.LowerVersion)) return fmt.Sprintf("%s/%s", pd.PackageWebLink(), url.PathEscape(pd.Version.LowerVersion))
@@ -267,6 +276,15 @@ func GetPackageDescriptors(ctx context.Context, pvs []*PackageVersion) ([]*Packa
return getPackageDescriptors(ctx, pvs, cache.NewEphemeralCache()) return getPackageDescriptors(ctx, pvs, cache.NewEphemeralCache())
} }
// GetAllPackageDescriptors gets all package descriptors for a package
func GetAllPackageDescriptors(ctx context.Context, p *Package) ([]*PackageDescriptor, error) {
pvs := make([]*PackageVersion, 0, 10)
if err := db.GetEngine(ctx).Where("package_id = ?", p.ID).Find(&pvs); err != nil {
return nil, err
}
return getPackageDescriptors(ctx, pvs, cache.NewEphemeralCache())
}
func getPackageDescriptors(ctx context.Context, pvs []*PackageVersion, c *cache.EphemeralCache) ([]*PackageDescriptor, error) { func getPackageDescriptors(ctx context.Context, pvs []*PackageVersion, c *cache.EphemeralCache) ([]*PackageDescriptor, error) {
pds := make([]*PackageDescriptor, 0, len(pvs)) pds := make([]*PackageDescriptor, 0, len(pvs))
for _, pv := range pvs { for _, pv := range pvs {
+14
View File
@@ -115,6 +115,20 @@ func DeleteFileByID(ctx context.Context, fileID int64) error {
return err return err
} }
// DeleteFilesByPackageID deletes all files of a specific package
// Versions must not be deleted prior to this call
func DeleteFilesByPackageID(ctx context.Context, packageID int64) error {
deleteStmt := builder.Delete(builder.In("version_id", builder.Select("package_version.id").From("package_version").Where(builder.Eq{"package_id": packageID}))).From("package_file")
_, err := db.GetEngine(ctx).Exec(deleteStmt)
return err
}
// DeleteFilesByVersionID deletes all files of a specific version
func DeleteFilesByVersionID(ctx context.Context, versionID int64) error {
_, err := db.GetEngine(ctx).Where("version_id = ?", versionID).Delete(&PackageFile{})
return err
}
func UpdateFile(ctx context.Context, pf *PackageFile, cols []string) error { func UpdateFile(ctx context.Context, pf *PackageFile, cols []string) error {
_, err := db.GetEngine(ctx).ID(pf.ID).Cols(cols...).Update(pf) _, err := db.GetEngine(ctx).ID(pf.ID).Cols(cols...).Update(pf)
return err return err
+41
View File
@@ -5,6 +5,7 @@ package packages
import ( import (
"context" "context"
"errors"
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
@@ -86,6 +87,46 @@ func DeleteAllProperties(ctx context.Context, refType PropertyType, refID int64)
return err return err
} }
// DeletePropertiesByPackageID deletes properties of a typed linked to the package
// Use to avoid for loops in mass deletion of properties
func DeletePropertiesByPackageID(ctx context.Context, refType PropertyType, packageID int64) error {
var deleteStmt *builder.Builder
switch refType {
case PropertyTypeFile:
deleteStmt = builder.Delete(
// Delete all properties that are attached to a file and are in ids from a subquery
// which returns ids from the package_file table joined on package_version to link it with package id
builder.Eq{"ref_type": PropertyTypeFile}, builder.In("ref_id",
builder.Select("package_file.id").From("package_file").
LeftJoin("package_version", "package_file.version_id = package_version.id").
Where(builder.Eq{"package_version.package_id": packageID}))).From("package_property")
case PropertyTypeVersion:
// Delete all properties that are attached to a version and are in ids from subquery to the package_version filtered by package id
deleteStmt = builder.Delete(
builder.Eq{"ref_type": PropertyTypeVersion}, builder.In("ref_id",
builder.Select("package_version.id").From("package_version").
Where(builder.Eq{"package_version.package_id": packageID}))).From("package_property")
case PropertyTypePackage:
// Delete all properties that are attached to a package and their reference links to the given package ID
deleteStmt = builder.Delete(
builder.Eq{"ref_type": PropertyTypePackage}, builder.Eq{"ref_id": packageID}).
From("package_property")
default:
return errors.New("invalid ref type")
}
_, err := db.GetEngine(ctx).Exec(deleteStmt)
return err
}
// DeleteFilePropertiesByVersionID deletes all file properties linked to specific version
func DeleteFilePropertiesByVersionID(ctx context.Context, versionID int64) error {
deleteStmt := builder.Delete(builder.Eq{"ref_type": PropertyTypeFile}, builder.In("ref_id", builder.Select("id").From("package_file").Where(builder.Eq{"version_id": versionID}))).From("package_property")
_, err := db.GetEngine(ctx).Exec(deleteStmt)
return err
}
// DeletePropertyByID deletes a property // DeletePropertyByID deletes a property
func DeletePropertyByID(ctx context.Context, propertyID int64) error { func DeletePropertyByID(ctx context.Context, propertyID int64) error {
_, err := db.GetEngine(ctx).ID(propertyID).Delete(&PackageProperty{}) _, err := db.GetEngine(ctx).ID(propertyID).Delete(&PackageProperty{})
+6
View File
@@ -157,6 +157,12 @@ func DeleteVersionByID(ctx context.Context, versionID int64) error {
return err return err
} }
// DeleteVersionsByPackageID deletes all versions of a specific package
func DeleteVersionsByPackageID(ctx context.Context, packageID int64) error {
_, err := db.GetEngine(ctx).Where(builder.Eq{"package_id": packageID}).Delete(&PackageVersion{})
return err
}
// HasVersionFileReferences checks if there are associated files // HasVersionFileReferences checks if there are associated files
func HasVersionFileReferences(ctx context.Context, versionID int64) (bool, error) { func HasVersionFileReferences(ctx context.Context, versionID int64) (bool, error) {
return db.GetEngine(ctx).Get(&PackageFile{ return db.GetEngine(ctx).Get(&PackageFile{
+6
View File
@@ -3506,6 +3506,7 @@
"packages.dependencies": "Dependencies", "packages.dependencies": "Dependencies",
"packages.keywords": "Keywords", "packages.keywords": "Keywords",
"packages.details": "Details", "packages.details": "Details",
"packages.name": "Package Name",
"packages.details.author": "Author", "packages.details.author": "Author",
"packages.details.project_site": "Project Site", "packages.details.project_site": "Project Site",
"packages.details.repository_site": "Repository Site", "packages.details.repository_site": "Repository Site",
@@ -3614,8 +3615,13 @@
"packages.settings.delete": "Delete package", "packages.settings.delete": "Delete package",
"packages.settings.delete.description": "Deleting a package is permanent and cannot be undone.", "packages.settings.delete.description": "Deleting a package is permanent and cannot be undone.",
"packages.settings.delete.notice": "You are about to delete %s (%s). This operation is irreversible, are you sure?", "packages.settings.delete.notice": "You are about to delete %s (%s). This operation is irreversible, are you sure?",
"packages.settings.delete.notice.package": "You are about to delete %s and all its versions. This operation is irreversible, are you sure?",
"packages.settings.delete.success": "The package has been deleted.", "packages.settings.delete.success": "The package has been deleted.",
"packages.settings.delete.version.success": "The package version has been deleted.",
"packages.settings.delete.error": "Failed to delete the package.", "packages.settings.delete.error": "Failed to delete the package.",
"packages.settings.delete.version": "Delete version",
"packages.settings.delete.confirm": "Enter package name to confirm",
"packages.settings.delete.invalid_package_name": "The package name you entered is incorrect.",
"packages.owner.settings.cargo.title": "Cargo Registry Index", "packages.owner.settings.cargo.title": "Cargo Registry Index",
"packages.owner.settings.cargo.initialize": "Initialize 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.", "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.",
+2 -1
View File
@@ -1578,10 +1578,11 @@ func Routes() *web.Router {
m.Group("/packages/{username}", func() { m.Group("/packages/{username}", func() {
m.Group("/{type}/{name}", func() { m.Group("/{type}/{name}", func() {
m.Get("/", packages.ListPackageVersions) m.Get("/", packages.ListPackageVersions)
m.Delete("", reqPackageAccess(perm.AccessModeWrite), packages.DeletePackage)
m.Group("/{version}", func() { m.Group("/{version}", func() {
m.Get("", packages.GetPackage) m.Get("", packages.GetPackage)
m.Delete("", reqPackageAccess(perm.AccessModeWrite), packages.DeletePackage) m.Delete("", reqPackageAccess(perm.AccessModeWrite), packages.DeletePackageVersion)
m.Get("/files", packages.ListPackageFiles) m.Get("/files", packages.ListPackageFiles)
}) })
+36 -1
View File
@@ -118,7 +118,7 @@ func GetPackage(ctx *context.APIContext) {
// DeletePackage deletes a package // DeletePackage deletes a package
func DeletePackage(ctx *context.APIContext) { func DeletePackage(ctx *context.APIContext) {
// swagger:operation DELETE /packages/{owner}/{type}/{name}/{version} package deletePackage // swagger:operation DELETE /packages/{owner}/{type}/{name} package deletePackage
// --- // ---
// summary: Delete a package // summary: Delete a package
// parameters: // parameters:
@@ -137,6 +137,41 @@ func DeletePackage(ctx *context.APIContext) {
// description: name of the package // description: name of the package
// type: string // type: string
// required: true // required: true
// responses:
// "204":
// "$ref": "#/responses/empty"
// "404":
// "$ref": "#/responses/notFound"
err := packages_service.RemovePackage(ctx, ctx.Doer, ctx.Package.Descriptor.Package)
if err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.Status(http.StatusNoContent)
}
// DeletePackageVersion deletes a package version
func DeletePackageVersion(ctx *context.APIContext) {
// swagger:operation DELETE /packages/{owner}/{type}/{name}/{version} package deletePackageVersion
// ---
// summary: Delete a package version
// parameters:
// - name: owner
// in: path
// description: owner of the package
// type: string
// required: true
// - name: type
// in: path
// description: type of the package
// type: string
// required: true
// - name: name
// in: path
// description: name of the package
// type: string
// required: true
// - name: version // - name: version
// in: path // in: path
// description: version of the package // description: version of the package
+1 -1
View File
@@ -93,7 +93,7 @@ func DeletePackageVersion(ctx *context.Context) {
return return
} }
ctx.Flash.Success(ctx.Tr("packages.settings.delete.success")) ctx.Flash.Success(ctx.Tr("packages.settings.delete.version.success"))
ctx.JSONRedirect(setting.AppSubURL + "/-/admin/packages?page=" + url.QueryEscape(ctx.FormString("page")) + "&q=" + url.QueryEscape(ctx.FormString("q")) + "&type=" + url.QueryEscape(ctx.FormString("type"))) ctx.JSONRedirect(setting.AppSubURL + "/-/admin/packages?page=" + url.QueryEscape(ctx.FormString("page")) + "&q=" + url.QueryEscape(ctx.FormString("q")) + "&type=" + url.QueryEscape(ctx.FormString("type")))
} }
+30 -5
View File
@@ -491,18 +491,43 @@ func packageSettingsPostActionLink(ctx *context.Context, form *forms.PackageSett
} }
func packageSettingsPostActionDelete(ctx *context.Context) { func packageSettingsPostActionDelete(ctx *context.Context) {
err := packages_service.RemovePackageVersion(ctx, ctx.Doer, ctx.Package.Descriptor.Version) pd := ctx.Package.Descriptor
if err != nil {
if ctx.FormString("package_name") != pd.Package.Name {
ctx.Flash.Error(ctx.Tr("packages.settings.delete.invalid_package_name"))
ctx.Redirect(pd.PackageSettingsLink())
return
}
if err := packages_service.RemovePackage(ctx, ctx.Doer, pd.Package); err != nil {
log.Error("Error deleting package: %v", err) log.Error("Error deleting package: %v", err)
ctx.Flash.Error(ctx.Tr("packages.settings.delete.error")) ctx.Flash.Error(ctx.Tr("packages.settings.delete.error"))
} else { } else {
ctx.Flash.Success(ctx.Tr("packages.settings.delete.success")) ctx.Flash.Success(ctx.Tr("packages.settings.delete.success"))
} }
ctx.Redirect(ctx.Package.Owner.HomeLink() + "/-/packages")
}
// PackageVersionDelete deletes a package version
func PackageVersionDelete(ctx *context.Context) {
pd := ctx.Package.Descriptor
if pd.Version == nil {
ctx.NotFound(nil)
return
}
if err := packages_service.RemovePackageVersion(ctx, ctx.Doer, pd.Version); err != nil {
log.Error("Error deleting package version: %v", err)
ctx.Flash.Error(ctx.Tr("packages.settings.delete.error"))
} else {
ctx.Flash.Success(ctx.Tr("packages.settings.delete.version.success"))
}
redirectURL := ctx.Package.Owner.HomeLink() + "/-/packages" redirectURL := ctx.Package.Owner.HomeLink() + "/-/packages"
// redirect to the package if there are still versions available // redirect to the package if there are still versions available
if has, _ := packages_model.ExistVersion(ctx, &packages_model.PackageSearchOptions{PackageID: ctx.Package.Descriptor.Package.ID, IsInternal: optional.Some(false)}); has { if has, _ := packages_model.ExistVersion(ctx, &packages_model.PackageSearchOptions{PackageID: pd.Package.ID, IsInternal: optional.Some(false)}); has {
redirectURL = ctx.Package.Descriptor.PackageWebLink() redirectURL = pd.PackageWebLink()
} }
ctx.Redirect(redirectURL) ctx.Redirect(redirectURL)
@@ -512,7 +537,7 @@ func packageSettingsPostActionDelete(ctx *context.Context) {
func DownloadPackageFile(ctx *context.Context) { func DownloadPackageFile(ctx *context.Context) {
pf, err := packages_model.GetFileForVersionByID(ctx, ctx.Package.Descriptor.Version.ID, ctx.PathParamInt64("fileid")) pf, err := packages_model.GetFileForVersionByID(ctx, ctx.Package.Descriptor.Version.ID, ctx.PathParamInt64("fileid"))
if err != nil { if err != nil {
if err == packages_model.ErrPackageFileNotExist { if errors.Is(err, packages_model.ErrPackageFileNotExist) {
ctx.NotFound(err) ctx.NotFound(err)
} else { } else {
ctx.ServerError("GetFileForVersionByID", err) ctx.ServerError("GetFileForVersionByID", err)
+5 -4
View File
@@ -1071,14 +1071,15 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Get("/versions", user.ListPackageVersions) m.Get("/versions", user.ListPackageVersions)
m.Group("/{version}", func() { m.Group("/{version}", func() {
m.Get("", user.ViewPackageVersion) m.Get("", user.ViewPackageVersion)
m.Post("", reqPackageAccess(perm.AccessModeWrite), user.PackageVersionDelete)
m.Get("/{version_sub}", user.ViewPackageVersion) m.Get("/{version_sub}", user.ViewPackageVersion)
m.Get("/files/{fileid}", user.DownloadPackageFile) m.Get("/files/{fileid}", user.DownloadPackageFile)
m.Group("/settings", func() {
m.Get("", user.PackageSettings)
m.Post("", web.Bind(forms.PackageSettingForm{}), user.PackageSettingsPost)
}, reqPackageAccess(perm.AccessModeWrite))
}) })
}) })
m.Group("/settings/{type}/{name}", func() {
m.Get("", user.PackageSettings)
m.Post("", web.Bind(forms.PackageSettingForm{}), user.PackageSettingsPost)
}, reqPackageAccess(perm.AccessModeWrite))
}, context.PackageAssignment(), reqPackageAccess(perm.AccessModeRead)) }, context.PackageAssignment(), reqPackageAccess(perm.AccessModeRead))
} }
+37 -16
View File
@@ -4,6 +4,7 @@
package context package context
import ( import (
"errors"
"fmt" "fmt"
"net/http" "net/http"
@@ -58,23 +59,28 @@ func PackageAssignmentAPI() func(ctx *APIContext) {
} }
func packageAssignment(ctx *packageAssignmentCtx, errCb func(int, any)) *Package { func packageAssignment(ctx *packageAssignmentCtx, errCb func(int, any)) *Package {
pkg := &Package{ pkgOwner := ctx.ContextUser
Owner: ctx.ContextUser, accessMode, err := determineAccessMode(ctx.Base, pkgOwner, ctx.Doer)
}
var err error
pkg.AccessMode, err = determineAccessMode(ctx.Base, pkg, ctx.Doer)
if err != nil { if err != nil {
errCb(http.StatusInternalServerError, fmt.Errorf("determineAccessMode: %w", err)) errCb(http.StatusInternalServerError, fmt.Errorf("determineAccessMode: %w", err))
return nil
}
pkg := &Package{
Owner: pkgOwner,
AccessMode: accessMode,
}
packageType := ctx.PathParam("type")
name := ctx.PathParam("name")
if packageType == "" || name == "" {
return pkg return pkg
} }
packageType := ctx.PathParam("type")
name := ctx.PathParam("name")
version := ctx.PathParam("version") version := ctx.PathParam("version")
if packageType != "" && name != "" && version != "" { if version != "" {
pv, err := packages_model.GetVersionByNameAndVersion(ctx, pkg.Owner.ID, packages_model.Type(packageType), name, version) pv, err := packages_model.GetVersionByNameAndVersion(ctx, pkg.Owner.ID, packages_model.Type(packageType), name, version)
if err != nil { if err != nil {
if err == packages_model.ErrPackageNotExist { if errors.Is(err, packages_model.ErrPackageNotExist) {
errCb(http.StatusNotFound, fmt.Errorf("GetVersionByNameAndVersion: %w", err)) errCb(http.StatusNotFound, fmt.Errorf("GetVersionByNameAndVersion: %w", err))
} else { } else {
errCb(http.StatusInternalServerError, fmt.Errorf("GetVersionByNameAndVersion: %w", err)) errCb(http.StatusInternalServerError, fmt.Errorf("GetVersionByNameAndVersion: %w", err))
@@ -87,12 +93,27 @@ func packageAssignment(ctx *packageAssignmentCtx, errCb func(int, any)) *Package
errCb(http.StatusInternalServerError, fmt.Errorf("GetPackageDescriptor: %w", err)) errCb(http.StatusInternalServerError, fmt.Errorf("GetPackageDescriptor: %w", err))
return pkg return pkg
} }
} else {
p, err := packages_model.GetPackageByName(ctx, pkg.Owner.ID, packages_model.Type(packageType), name)
if err != nil {
if errors.Is(err, packages_model.ErrPackageNotExist) {
errCb(http.StatusNotFound, fmt.Errorf("GetPackageByName: %w", err))
} else {
errCb(http.StatusInternalServerError, fmt.Errorf("GetPackageByName: %w", err))
}
return pkg
}
pkg.Descriptor = &packages_model.PackageDescriptor{
Package: p,
Owner: pkg.Owner,
}
} }
return pkg return pkg
} }
func determineAccessMode(ctx *Base, pkg *Package, doer *user_model.User) (perm.AccessMode, error) { func determineAccessMode(ctx *Base, pkgOwner, doer *user_model.User) (perm.AccessMode, error) {
if setting.Service.RequireSignInViewStrict && (doer == nil || doer.IsGhost()) { if setting.Service.RequireSignInViewStrict && (doer == nil || doer.IsGhost()) {
return perm.AccessModeNone, nil return perm.AccessModeNone, nil
} }
@@ -103,8 +124,8 @@ func determineAccessMode(ctx *Base, pkg *Package, doer *user_model.User) (perm.A
// TODO: ActionUser permission check // TODO: ActionUser permission check
accessMode := perm.AccessModeNone accessMode := perm.AccessModeNone
if pkg.Owner.IsOrganization() { if pkgOwner.IsOrganization() {
org := organization.OrgFromUser(pkg.Owner) org := organization.OrgFromUser(pkgOwner)
if doer != nil && !doer.IsGhost() { if doer != nil && !doer.IsGhost() {
// 1. If user is logged in, check all team packages permissions // 1. If user is logged in, check all team packages permissions
@@ -128,19 +149,19 @@ func determineAccessMode(ctx *Base, pkg *Package, doer *user_model.User) (perm.A
} }
} }
} }
if accessMode == perm.AccessModeNone && organization.HasOrgOrUserVisible(ctx, pkg.Owner, doer) { if accessMode == perm.AccessModeNone && organization.HasOrgOrUserVisible(ctx, pkgOwner, doer) {
// 2. If user is unauthorized or no org member, check if org is visible // 2. If user is unauthorized or no org member, check if org is visible
accessMode = perm.AccessModeRead accessMode = perm.AccessModeRead
} }
} else { } else {
if doer != nil && !doer.IsGhost() { if doer != nil && !doer.IsGhost() {
// 1. Check if user is package owner // 1. Check if user is package owner
if doer.ID == pkg.Owner.ID { if doer.ID == pkgOwner.ID {
accessMode = perm.AccessModeOwner accessMode = perm.AccessModeOwner
} else if pkg.Owner.Visibility == structs.VisibleTypePublic || pkg.Owner.Visibility == structs.VisibleTypeLimited { // 2. Check if package owner is public or limited } else if pkgOwner.Visibility == structs.VisibleTypePublic || pkgOwner.Visibility == structs.VisibleTypeLimited { // 2. Check if package owner is public or limited
accessMode = perm.AccessModeRead accessMode = perm.AccessModeRead
} }
} else if pkg.Owner.Visibility == structs.VisibleTypePublic { // 3. Check if package owner is public } else if pkgOwner.Visibility == structs.VisibleTypePublic { // 3. Check if package owner is public
accessMode = perm.AccessModeRead accessMode = perm.AccessModeRead
} }
} }
+1
View File
@@ -184,6 +184,7 @@ func CleanupExpiredData(ctx context.Context, olderThan time.Duration) error {
} }
} }
// HINT: PACKAGE-DEFER-STORAGE-DELETE: Handle blob deletion for package storage
pbs, err = packages_model.FindExpiredUnreferencedBlobs(ctx, olderThan) pbs, err = packages_model.FindExpiredUnreferencedBlobs(ctx, olderThan)
if err != nil { if err != nil {
return err return err
+45 -9
View File
@@ -473,7 +473,8 @@ func RemovePackageVersion(ctx context.Context, doer *user_model.User, pv *packag
if err != nil { if err != nil {
return err return err
} }
// HINT: PACKAGE-DEFER-STORAGE-DELETE: Blobs are not deleted immediately, instead they are deleted by the cleanup_packages cron task.
// If there are no more versions for the package, the same task removes that as well.
if err := db.WithTx(ctx, func(ctx context.Context) error { if err := db.WithTx(ctx, func(ctx context.Context) error {
log.Trace("Deleting package: %v", pv.ID) log.Trace("Deleting package: %v", pv.ID)
return DeletePackageVersionAndReferences(ctx, pv) return DeletePackageVersionAndReferences(ctx, pv)
@@ -532,16 +533,11 @@ func DeletePackageVersionAndReferences(ctx context.Context, pv *packages_model.P
if err := packages_model.DeleteAllProperties(ctx, packages_model.PropertyTypeVersion, pv.ID); err != nil { if err := packages_model.DeleteAllProperties(ctx, packages_model.PropertyTypeVersion, pv.ID); err != nil {
return err return err
} }
if err := packages_model.DeleteFilePropertiesByVersionID(ctx, pv.ID); err != nil {
pfs, err := packages_model.GetFilesByVersionID(ctx, pv.ID)
if err != nil {
return err return err
} }
if err := packages_model.DeleteFilesByVersionID(ctx, pv.ID); err != nil {
for _, pf := range pfs { return err
if err := DeletePackageFile(ctx, pf); err != nil {
return err
}
} }
return packages_model.DeleteVersionByID(ctx, pv.ID) return packages_model.DeleteVersionByID(ctx, pv.ID)
@@ -629,6 +625,46 @@ func OpenBlobForDownload(ctx context.Context, pf *packages_model.PackageFile, pb
return s, u, pf, nil return s, u, pf, nil
} }
// RemovePackage deletes the package and all its versions
func RemovePackage(ctx context.Context, doer *user_model.User, p *packages_model.Package) error {
pds, err := packages_model.GetAllPackageDescriptors(ctx, p)
if err != nil {
return err
}
// HINT: PACKAGE-DEFER-STORAGE-DELETE: Blobs are not deleted immediately, instead they are deleted by cleanup_packages cron task.
err = db.WithTx(ctx, func(ctx context.Context) error {
err := packages_model.DeletePropertiesByPackageID(ctx, packages_model.PropertyTypePackage, p.ID)
if err != nil {
return err
}
err = packages_model.DeletePropertiesByPackageID(ctx, packages_model.PropertyTypeFile, p.ID)
if err != nil {
return err
}
err = packages_model.DeletePropertiesByPackageID(ctx, packages_model.PropertyTypeVersion, p.ID)
if err != nil {
return err
}
err = packages_model.DeleteFilesByPackageID(ctx, p.ID)
if err != nil {
return err
}
err = packages_model.DeleteVersionsByPackageID(ctx, p.ID)
if err != nil {
return err
}
return packages_model.DeletePackageByID(ctx, p.ID)
})
if err != nil {
return err
}
for _, pd := range pds {
notify_service.PackageDelete(ctx, doer, pd)
}
return nil
}
// RemoveAllPackages for User // RemoveAllPackages for User
func RemoveAllPackages(ctx context.Context, userID int64) (int, error) { func RemoveAllPackages(ctx context.Context, userID int64) (int, error) {
count := 0 count := 0
+1 -1
View File
@@ -89,7 +89,7 @@
</div> </div>
<form class="ui small modal form-fetch-action" method="post" id="admin-package-delete-modal"> <form class="ui small modal form-fetch-action" method="post" id="admin-package-delete-modal">
<div class="header">{{svg "octicon-trash"}} {{ctx.Locale.Tr "packages.settings.delete"}}</div> <div class="header">{{svg "octicon-trash"}} {{ctx.Locale.Tr "packages.settings.delete.version"}}</div>
<div class="content"> <div class="content">
{{ctx.Locale.Tr "packages.settings.delete.notice" (HTMLFormat `<span class="%s"></span>` "package-name") (HTMLFormat `<span class="%s"></span>` "package-version")}} {{ctx.Locale.Tr "packages.settings.delete.notice" (HTMLFormat `<span class="%s"></span>` "package-name") (HTMLFormat `<span class="%s"></span>` "package-version")}}
</div> </div>
+12 -2
View File
@@ -10,7 +10,7 @@
{{template "user/overview/header" .}} {{template "user/overview/header" .}}
{{end}} {{end}}
{{template "base/alert" .}} {{template "base/alert" .}}
<p><a href="{{.PackageDescriptor.VersionWebLink}}">{{.PackageDescriptor.Package.Name}} ({{.PackageDescriptor.Version.Version}})</a> / <strong>{{ctx.Locale.Tr "repo.settings"}}</strong></p> <p><a href="{{.PackageDescriptor.PackageWebLink}}">{{.PackageDescriptor.Package.Name}}</a> / <strong>{{ctx.Locale.Tr "repo.settings"}}</strong></p>
<h4 class="ui top attached header"> <h4 class="ui top attached header">
{{ctx.Locale.Tr "packages.settings.link"}} {{ctx.Locale.Tr "packages.settings.link"}}
</h4> </h4>
@@ -45,10 +45,20 @@
</div> </div>
<div class="content"> <div class="content">
<div class="ui warning message tw-break-anywhere"> <div class="ui warning message tw-break-anywhere">
{{ctx.Locale.Tr "packages.settings.delete.notice" .PackageDescriptor.Package.Name .PackageDescriptor.Version.Version}} {{ctx.Locale.Tr "packages.settings.delete.notice.package" .PackageDescriptor.Package.Name}}
</div> </div>
<form class="ui form" action="{{.Link}}" method="post"> <form class="ui form" action="{{.Link}}" method="post">
<input type="hidden" name="action" value="delete"> <input type="hidden" name="action" value="delete">
<div class="field">
<label>
{{ctx.Locale.Tr "packages.settings.delete.confirm"}}
<span class="tw-text-red">{{.PackageDescriptor.Package.Name}}</span>
</label>
</div>
<div class="required field">
<label>{{ctx.Locale.Tr "packages.name"}}</label>
<input name="package_name" required maxlength="100">
</div>
{{template "base/modal_actions_confirm" .}} {{template "base/modal_actions_confirm" .}}
</form> </form>
</div> </div>
+18 -1
View File
@@ -99,7 +99,24 @@
<div class="item">{{svg "octicon-issue-opened"}} <a href="{{.PackageDescriptor.Repository.Link}}/issues">{{ctx.Locale.Tr "repo.issues"}}</a></div> <div class="item">{{svg "octicon-issue-opened"}} <a href="{{.PackageDescriptor.Repository.Link}}/issues">{{ctx.Locale.Tr "repo.issues"}}</a></div>
{{end}} {{end}}
{{if .CanWritePackages}} {{if .CanWritePackages}}
<div class="item">{{svg "octicon-tools"}} <a href="{{$packageVersionLink}}/settings">{{ctx.Locale.Tr "repo.settings"}}</a></div> <div class="item">{{svg "octicon-tools"}} <a href="{{.PackageDescriptor.PackageSettingsLink}}">{{ctx.Locale.Tr "repo.settings"}}</a></div>
<div class="item">
{{svg "octicon-trash"}}
<a class="show-modal" href data-modal="#delete-package-version-modal">{{ctx.Locale.Tr "packages.settings.delete.version"}}</a>
<div class="ui tiny modal" id="delete-package-version-modal">
<div class="header">
{{ctx.Locale.Tr "packages.settings.delete.version"}}
</div>
<div class="content">
<div class="ui warning message tw-break-anywhere">
{{ctx.Locale.Tr "packages.settings.delete.notice" .PackageDescriptor.Package.Name .PackageDescriptor.Version.Version}}
</div>
<form class="ui form" action="{{$packageVersionLink}}" method="post">
{{template "base/modal_actions_confirm" .}}
</form>
</div>
</div>
</div>
{{end}} {{end}}
</div> </div>
{{end}} {{end}}
+40 -2
View File
@@ -3912,6 +3912,44 @@
"$ref": "#/responses/notFound" "$ref": "#/responses/notFound"
} }
} }
},
"delete": {
"tags": [
"package"
],
"summary": "Delete a package",
"operationId": "deletePackage",
"parameters": [
{
"type": "string",
"description": "owner of the package",
"name": "owner",
"in": "path",
"required": true
},
{
"type": "string",
"description": "type of the package",
"name": "type",
"in": "path",
"required": true
},
{
"type": "string",
"description": "name of the package",
"name": "name",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"$ref": "#/responses/empty"
},
"404": {
"$ref": "#/responses/notFound"
}
}
} }
}, },
"/packages/{owner}/{type}/{name}/-/latest": { "/packages/{owner}/{type}/{name}/-/latest": {
@@ -4097,8 +4135,8 @@
"tags": [ "tags": [
"package" "package"
], ],
"summary": "Delete a package", "summary": "Delete a package version",
"operationId": "deletePackage", "operationId": "deletePackageVersion",
"parameters": [ "parameters": [
{ {
"type": "string", "type": "string",
+44
View File
@@ -85,6 +85,50 @@ func TestPackageAPI(t *testing.T) {
assert.Equal(t, user.Name, p.Creator.UserName) assert.Equal(t, user.Name, p.Creator.UserName)
}) })
t.Run("DeleteEntirePackage", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
packageName := "test-package-entire-delete"
for _, version := range []string{"1.0.1", "1.0.2"} {
url := fmt.Sprintf("/api/packages/%s/generic/%s/%s/file.bin", user.Name, packageName, version)
req := NewRequestWithBody(t, "PUT", url, bytes.NewReader([]byte{1})).
AddBasicAuth(user.Name)
MakeRequest(t, req, http.StatusCreated)
}
req := NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/packages/%s/generic/%s", user.Name, packageName)).
AddTokenAuth(tokenWritePackage)
MakeRequest(t, req, http.StatusNoContent)
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/packages/%s/generic/%s", user.Name, packageName)).
AddTokenAuth(tokenReadPackage)
MakeRequest(t, req, http.StatusNotFound)
})
t.Run("DeletePackageVersion", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
packageName := "test-package-version-delete"
for _, version := range []string{"1.0.1", "1.0.2"} {
url := fmt.Sprintf("/api/packages/%s/generic/%s/%s/file.bin", user.Name, packageName, version)
req := NewRequestWithBody(t, "PUT", url, bytes.NewReader([]byte{1})).
AddBasicAuth(user.Name)
MakeRequest(t, req, http.StatusCreated)
}
req := NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/packages/%s/generic/%s/1.0.1", user.Name, packageName)).
AddTokenAuth(tokenWritePackage)
MakeRequest(t, req, http.StatusNoContent)
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/packages/%s/generic/%s/1.0.1", user.Name, packageName)).
AddTokenAuth(tokenReadPackage)
MakeRequest(t, req, http.StatusNotFound)
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/packages/%s/generic/%s/1.0.2", user.Name, packageName)).
AddTokenAuth(tokenReadPackage)
MakeRequest(t, req, http.StatusOK)
})
t.Run("ListPackageVersions", func(t *testing.T) { t.Run("ListPackageVersions", func(t *testing.T) {
defer tests.PrintCurrentTest(t)() defer tests.PrintCurrentTest(t)()
+118
View File
@@ -0,0 +1,118 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"bytes"
"testing"
packages_model "code.gitea.io/gitea/models/packages"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
packages_module "code.gitea.io/gitea/modules/packages"
packages_service "code.gitea.io/gitea/services/packages"
"code.gitea.io/gitea/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRemovePackage(t *testing.T) {
defer tests.PrepareTestEnv(t)()
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
// 1. Setup: Create two packages with properties at all levels
createPackage := func(name string) (*packages_model.Package, *packages_model.PackageVersion, *packages_model.PackageFile) {
data, _ := packages_module.CreateHashedBufferFromReader(bytes.NewReader([]byte{1}))
pv, pf, err := packages_service.CreatePackageOrAddFileToExisting(t.Context(), &packages_service.PackageCreationInfo{
PackageInfo: packages_service.PackageInfo{
Owner: user,
PackageType: packages_model.TypeGeneric,
Name: name,
Version: "1.0.0",
},
Creator: user,
PackageProperties: map[string]string{"pkg_prop": "val"},
VersionProperties: map[string]string{"ver_prop": "val"},
}, &packages_service.PackageFileCreationInfo{
PackageFileInfo: packages_service.PackageFileInfo{Filename: "file.bin"},
Creator: user,
Data: data,
Properties: map[string]string{"file_prop": "val"},
})
require.NoError(t, err)
p, err := packages_model.GetPackageByID(t.Context(), pv.PackageID)
require.NoError(t, err)
return p, pv, pf
}
p1, pv1, pf1 := createPackage("package-1")
p2, pv2, pf2 := createPackage("package-2")
// Verify properties exist before deletion
checkProps := func(p *packages_model.Package, pv *packages_model.PackageVersion, pf *packages_model.PackageFile, shouldExist bool) {
pps, err := packages_model.GetProperties(t.Context(), packages_model.PropertyTypePackage, p.ID)
require.NoError(t, err)
if shouldExist {
assert.NotEmpty(t, pps)
} else {
assert.Empty(t, pps)
}
pps, err = packages_model.GetProperties(t.Context(), packages_model.PropertyTypeVersion, pv.ID)
require.NoError(t, err)
if shouldExist {
assert.NotEmpty(t, pps)
} else {
assert.Empty(t, pps)
}
pps, err = packages_model.GetProperties(t.Context(), packages_model.PropertyTypeFile, pf.ID)
require.NoError(t, err)
if shouldExist {
assert.NotEmpty(t, pps)
} else {
assert.Empty(t, pps)
}
}
checkProps(p1, pv1, pf1, true)
checkProps(p2, pv2, pf2, true)
// 2. Act: Remove package 1
err := packages_service.RemovePackage(t.Context(), user, p1)
assert.NoError(t, err)
// 3. Assert: Package 1 is gone, Package 2 is untouched
// Check P1
_, err = packages_model.GetPackageByID(t.Context(), p1.ID)
assert.ErrorIs(t, err, packages_model.ErrPackageNotExist)
_, err = packages_model.GetVersionByID(t.Context(), pv1.ID)
assert.ErrorIs(t, err, packages_model.ErrPackageNotExist)
_, err = packages_model.GetFileForVersionByID(t.Context(), pv1.ID, pf1.ID)
assert.ErrorIs(t, err, packages_model.ErrPackageFileNotExist)
checkProps(p1, pv1, pf1, false)
// Check P2
p2After, err := packages_model.GetPackageByID(t.Context(), p2.ID)
assert.NoError(t, err)
assert.NotNil(t, p2After)
pv2After, err := packages_model.GetVersionByID(t.Context(), pv2.ID)
assert.NoError(t, err)
assert.NotNil(t, pv2After)
pf2After, err := packages_model.GetFileForVersionByID(t.Context(), pv2.ID, pf2.ID)
assert.NoError(t, err)
assert.NotNil(t, pf2After)
checkProps(p2, pv2, pf2, true)
}