From d8c16470d255f87332477d92ae134097de9e28fe Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Sun, 29 Mar 2026 12:04:37 +0200 Subject: [PATCH 1/8] move settings tab from package version to package --- options/locale/locale_en-US.json | 1 + routers/web/user/package.go | 27 +++++++++++++++--- routers/web/web.go | 22 ++++++++------- services/context/package.go | 45 ++++++++++++++++++++---------- services/packages/packages.go | 19 +++++++++++++ templates/package/settings.tmpl | 4 +-- templates/package/shared/view.tmpl | 19 ++++++++++++- 7 files changed, 106 insertions(+), 31 deletions(-) diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 2ffa130751..e9a7496a98 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -3607,6 +3607,7 @@ "packages.settings.delete": "Delete package", "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.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.error": "Failed to delete the package.", "packages.owner.settings.cargo.title": "Cargo Registry Index", diff --git a/routers/web/user/package.go b/routers/web/user/package.go index ffbfaa229b..ab46f284d3 100644 --- a/routers/web/user/package.go +++ b/routers/web/user/package.go @@ -491,18 +491,37 @@ func packageSettingsPostActionLink(ctx *context.Context, form *forms.PackageSett } func packageSettingsPostActionDelete(ctx *context.Context) { - err := packages_service.RemovePackageVersion(ctx, ctx.Doer, ctx.Package.Descriptor.Version) - if err != nil { + pd := ctx.Package.Descriptor + + if err := packages_service.RemovePackage(ctx, ctx.Doer, pd.Package); err != nil { log.Error("Error deleting package: %v", err) ctx.Flash.Error(ctx.Tr("packages.settings.delete.error")) } else { 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.success")) + } + redirectURL := ctx.Package.Owner.HomeLink() + "/-/packages" // 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 { - redirectURL = ctx.Package.Descriptor.PackageWebLink() + if has, _ := packages_model.ExistVersion(ctx, &packages_model.PackageSearchOptions{PackageID: pd.Package.ID, IsInternal: optional.Some(false)}); has { + redirectURL = pd.PackageWebLink() } ctx.Redirect(redirectURL) diff --git a/routers/web/web.go b/routers/web/web.go index 72d2c27eaf..32aa4368cc 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1072,16 +1072,18 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Get("", user.ListPackages) m.Group("/{type}/{name}", func() { m.Get("", user.RedirectToLastVersion) - m.Get("/versions", user.ListPackageVersions) - m.Group("/{version}", func() { - m.Get("", user.ViewPackageVersion) - m.Get("/{version_sub}", user.ViewPackageVersion) - 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)) - }) + // use `~` as a separator; otherwise it might clash with a user package named `-` or `settings` + m.Group("/~/settings", func() { + m.Get("", user.PackageSettings) + m.Post("", web.Bind(forms.PackageSettingForm{}), user.PackageSettingsPost) + }, reqPackageAccess(perm.AccessModeWrite)) + }) + m.Get("/versions", user.ListPackageVersions) + m.Group("/{version}", func() { + m.Get("", user.ViewPackageVersion) + m.Post("", reqPackageAccess(perm.AccessModeWrite), user.PackageVersionDelete) + m.Get("/{version_sub}", user.ViewPackageVersion) + m.Get("/files/{fileid}", user.DownloadPackageFile) }) }, context.PackageAssignment(), reqPackageAccess(perm.AccessModeRead)) } diff --git a/services/context/package.go b/services/context/package.go index 0e9210515b..4aee80b8a6 100644 --- a/services/context/package.go +++ b/services/context/package.go @@ -70,22 +70,39 @@ func packageAssignment(ctx *packageAssignmentCtx, errCb func(int, any)) *Package packageType := ctx.PathParam("type") 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)) + if packageType != "" && name != "" { + version := ctx.PathParam("version") + if 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)) + } + return pkg } - return pkg - } - pkg.Descriptor, err = packages_model.GetPackageDescriptor(ctx, pv) - if err != nil { - errCb(http.StatusInternalServerError, fmt.Errorf("GetPackageDescriptor: %w", err)) - return pkg + pkg.Descriptor, err = packages_model.GetPackageDescriptor(ctx, pv) + if err != nil { + errCb(http.StatusInternalServerError, fmt.Errorf("GetPackageDescriptor: %w", err)) + return pkg + } + } else { + p, err := packages_model.GetPackageByName(ctx, pkg.Owner.ID, packages_model.Type(packageType), name) + if err != nil { + if 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, + } } } diff --git a/services/packages/packages.go b/services/packages/packages.go index 3b4e11e041..85269dbe32 100644 --- a/services/packages/packages.go +++ b/services/packages/packages.go @@ -629,6 +629,25 @@ func OpenBlobForDownload(ctx context.Context, pf *packages_model.PackageFile, pb 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 { + pvs, _, err := packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{ + PackageID: p.ID, + IsInternal: optional.None[bool](), + }) + if err != nil { + return err + } + + for _, pv := range pvs { + if err := RemovePackageVersion(ctx, doer, pv); err != nil { + return err + } + } + + return packages_model.DeletePackageByID(ctx, p.ID) +} + // RemoveAllPackages for User func RemoveAllPackages(ctx context.Context, userID int64) (int, error) { count := 0 diff --git a/templates/package/settings.tmpl b/templates/package/settings.tmpl index 0124f26820..4545575618 100644 --- a/templates/package/settings.tmpl +++ b/templates/package/settings.tmpl @@ -10,7 +10,7 @@ {{template "user/overview/header" .}} {{end}} {{template "base/alert" .}} -

{{.PackageDescriptor.Package.Name}} ({{.PackageDescriptor.Version.Version}}) / {{ctx.Locale.Tr "repo.settings"}}

+

{{.PackageDescriptor.Package.Name}} / {{ctx.Locale.Tr "repo.settings"}}

{{ctx.Locale.Tr "packages.settings.link"}}

@@ -45,7 +45,7 @@
- {{ctx.Locale.Tr "packages.settings.delete.notice" .PackageDescriptor.Package.Name .PackageDescriptor.Version.Version}} + {{ctx.Locale.Tr "packages.settings.delete.notice.package" .PackageDescriptor.Package.Name}}
diff --git a/templates/package/shared/view.tmpl b/templates/package/shared/view.tmpl index cfdc114e1b..6e0b08fb00 100644 --- a/templates/package/shared/view.tmpl +++ b/templates/package/shared/view.tmpl @@ -99,7 +99,24 @@
{{svg "octicon-issue-opened"}} {{ctx.Locale.Tr "repo.issues"}}
{{end}} {{if .CanWritePackages}} -
{{svg "octicon-tools"}} {{ctx.Locale.Tr "repo.settings"}}
+
{{svg "octicon-tools"}} {{ctx.Locale.Tr "repo.settings"}}
+
+ {{svg "octicon-trash"}} + {{ctx.Locale.Tr "packages.settings.delete"}} + +
{{end}}
{{end}} From ac1387d821b7817b42d60ed5dadf4238aff755fa Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Sun, 29 Mar 2026 12:35:36 +0200 Subject: [PATCH 2/8] fix router --- routers/web/web.go | 14 +++++++------- templates/package/shared/view.tmpl | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/routers/web/web.go b/routers/web/web.go index 32aa4368cc..4956f4b1ed 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1077,13 +1077,13 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Get("", user.PackageSettings) m.Post("", web.Bind(forms.PackageSettingForm{}), user.PackageSettingsPost) }, reqPackageAccess(perm.AccessModeWrite)) - }) - m.Get("/versions", user.ListPackageVersions) - m.Group("/{version}", func() { - m.Get("", user.ViewPackageVersion) - m.Post("", reqPackageAccess(perm.AccessModeWrite), user.PackageVersionDelete) - m.Get("/{version_sub}", user.ViewPackageVersion) - m.Get("/files/{fileid}", user.DownloadPackageFile) + m.Get("/versions", user.ListPackageVersions) + m.Group("/{version}", func() { + m.Get("", user.ViewPackageVersion) + m.Post("", reqPackageAccess(perm.AccessModeWrite), user.PackageVersionDelete) + m.Get("/{version_sub}", user.ViewPackageVersion) + m.Get("/files/{fileid}", user.DownloadPackageFile) + }) }) }, context.PackageAssignment(), reqPackageAccess(perm.AccessModeRead)) } diff --git a/templates/package/shared/view.tmpl b/templates/package/shared/view.tmpl index 6e0b08fb00..e2d385edc5 100644 --- a/templates/package/shared/view.tmpl +++ b/templates/package/shared/view.tmpl @@ -99,7 +99,7 @@
{{svg "octicon-issue-opened"}} {{ctx.Locale.Tr "repo.issues"}}
{{end}} {{if .CanWritePackages}} -
{{svg "octicon-tools"}} {{ctx.Locale.Tr "repo.settings"}}
+
{{svg "octicon-tools"}} {{ctx.Locale.Tr "repo.settings"}}
{{svg "octicon-trash"}} {{ctx.Locale.Tr "packages.settings.delete"}} From 417c5efd4c8488a050bd04f0dbd38208ba0c4486 Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Sun, 29 Mar 2026 13:29:25 +0200 Subject: [PATCH 3/8] use error checking --- services/context/package.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/services/context/package.go b/services/context/package.go index 4aee80b8a6..fede5cef94 100644 --- a/services/context/package.go +++ b/services/context/package.go @@ -4,6 +4,7 @@ package context import ( + "errors" "fmt" "net/http" @@ -75,7 +76,7 @@ func packageAssignment(ctx *packageAssignmentCtx, errCb func(int, any)) *Package if version != "" { pv, err := packages_model.GetVersionByNameAndVersion(ctx, pkg.Owner.ID, packages_model.Type(packageType), name, version) if err != nil { - if err == packages_model.ErrPackageNotExist { + if errors.Is(err, packages_model.ErrPackageNotExist) { errCb(http.StatusNotFound, fmt.Errorf("GetVersionByNameAndVersion: %w", err)) } else { errCb(http.StatusInternalServerError, fmt.Errorf("GetVersionByNameAndVersion: %w", err)) @@ -91,7 +92,7 @@ func packageAssignment(ctx *packageAssignmentCtx, errCb func(int, any)) *Package } else { p, err := packages_model.GetPackageByName(ctx, pkg.Owner.ID, packages_model.Type(packageType), name) if err != nil { - if err == packages_model.ErrPackageNotExist { + if errors.Is(err, packages_model.ErrPackageNotExist) { errCb(http.StatusNotFound, fmt.Errorf("GetPackageByName: %w", err)) } else { errCb(http.StatusInternalServerError, fmt.Errorf("GetPackageByName: %w", err)) From 48083bf28004c2251e8f6f0e59ef01201323e2c7 Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Mon, 30 Mar 2026 19:02:45 +0200 Subject: [PATCH 4/8] fix package removal not deleting everything --- models/packages/package_file.go | 15 ++++++++++ models/packages/package_property.go | 38 ++++++++++++++++++++++++ models/packages/package_version.go | 6 ++++ routers/web/user/package.go | 2 +- services/packages/packages.go | 46 ++++++++++++++++------------- 5 files changed, 85 insertions(+), 22 deletions(-) diff --git a/models/packages/package_file.go b/models/packages/package_file.go index bf877485d6..77cd163b23 100644 --- a/models/packages/package_file.go +++ b/models/packages/package_file.go @@ -115,6 +115,21 @@ func DeleteFileByID(ctx context.Context, fileID int64) error { 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 { _, err := db.GetEngine(ctx).ID(pf.ID).Cols(cols...).Update(pf) return err diff --git a/models/packages/package_property.go b/models/packages/package_property.go index acc05d8d5a..e61967c85d 100644 --- a/models/packages/package_property.go +++ b/models/packages/package_property.go @@ -86,6 +86,44 @@ func DeleteAllProperties(ctx context.Context, refType PropertyType, refID int64) 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") + } + + _, 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 func DeletePropertyByID(ctx context.Context, propertyID int64) error { _, err := db.GetEngine(ctx).ID(propertyID).Delete(&PackageProperty{}) diff --git a/models/packages/package_version.go b/models/packages/package_version.go index 0a478c0323..3e0e1899ea 100644 --- a/models/packages/package_version.go +++ b/models/packages/package_version.go @@ -157,6 +157,12 @@ func DeleteVersionByID(ctx context.Context, versionID int64) error { 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 func HasVersionFileReferences(ctx context.Context, versionID int64) (bool, error) { return db.GetEngine(ctx).Get(&PackageFile{ diff --git a/routers/web/user/package.go b/routers/web/user/package.go index ab46f284d3..f59a25d352 100644 --- a/routers/web/user/package.go +++ b/routers/web/user/package.go @@ -493,7 +493,7 @@ func packageSettingsPostActionLink(ctx *context.Context, form *forms.PackageSett func packageSettingsPostActionDelete(ctx *context.Context) { pd := ctx.Package.Descriptor - if err := packages_service.RemovePackage(ctx, ctx.Doer, pd.Package); err != nil { + if err := packages_service.RemovePackage(ctx, pd.Package); err != nil { log.Error("Error deleting package: %v", err) ctx.Flash.Error(ctx.Tr("packages.settings.delete.error")) } else { diff --git a/services/packages/packages.go b/services/packages/packages.go index 85269dbe32..16ec7a3598 100644 --- a/services/packages/packages.go +++ b/services/packages/packages.go @@ -532,16 +532,11 @@ func DeletePackageVersionAndReferences(ctx context.Context, pv *packages_model.P if err := packages_model.DeleteAllProperties(ctx, packages_model.PropertyTypeVersion, pv.ID); err != nil { return err } - - pfs, err := packages_model.GetFilesByVersionID(ctx, pv.ID) - if err != nil { + if err := packages_model.DeleteFilePropertiesByVersionID(ctx, pv.ID); err != nil { return err } - - for _, pf := range pfs { - if err := DeletePackageFile(ctx, pf); err != nil { - return err - } + if err := packages_model.DeleteFilesByVersionID(ctx, pv.ID); err != nil { + return err } return packages_model.DeleteVersionByID(ctx, pv.ID) @@ -630,22 +625,31 @@ func OpenBlobForDownload(ctx context.Context, pf *packages_model.PackageFile, pb } // RemovePackage deletes the package and all its versions -func RemovePackage(ctx context.Context, doer *user_model.User, p *packages_model.Package) error { - pvs, _, err := packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{ - PackageID: p.ID, - IsInternal: optional.None[bool](), - }) - if err != nil { - return err - } - - for _, pv := range pvs { - if err := RemovePackageVersion(ctx, doer, pv); err != nil { +func RemovePackage(ctx context.Context, p *packages_model.Package) error { + return 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) + return packages_model.DeletePackageByID(ctx, p.ID) + }) } // RemoveAllPackages for User From 8eac2669bba1637e386e1b450fdf1a497bb37517 Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Mon, 30 Mar 2026 20:29:53 +0200 Subject: [PATCH 5/8] formatting and error check switch --- models/packages/package_file.go | 1 - routers/web/user/package.go | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/models/packages/package_file.go b/models/packages/package_file.go index 77cd163b23..69401eee3e 100644 --- a/models/packages/package_file.go +++ b/models/packages/package_file.go @@ -125,7 +125,6 @@ func DeleteFilesByPackageID(ctx context.Context, packageID int64) error { // 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 } diff --git a/routers/web/user/package.go b/routers/web/user/package.go index f59a25d352..5eeb989899 100644 --- a/routers/web/user/package.go +++ b/routers/web/user/package.go @@ -531,7 +531,7 @@ func PackageVersionDelete(ctx *context.Context) { func DownloadPackageFile(ctx *context.Context) { pf, err := packages_model.GetFileForVersionByID(ctx, ctx.Package.Descriptor.Version.ID, ctx.PathParamInt64("fileid")) if err != nil { - if err == packages_model.ErrPackageFileNotExist { + if errors.Is(err, packages_model.ErrPackageFileNotExist) { ctx.NotFound(err) } else { ctx.ServerError("GetFileForVersionByID", err) From 0c5b4f11dbb0c7ce62c52657b1f547728164f63a Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Tue, 31 Mar 2026 09:14:56 +0200 Subject: [PATCH 6/8] separate routes to avoid conflict --- models/packages/descriptor.go | 5 +++++ routers/web/web.go | 9 ++++----- templates/package/shared/view.tmpl | 2 +- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/models/packages/descriptor.go b/models/packages/descriptor.go index ea0e0d5e73..be20465da0 100644 --- a/models/packages/descriptor.go +++ b/models/packages/descriptor.go @@ -77,6 +77,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)) } +// 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 func (pd *PackageDescriptor) VersionWebLink() string { return fmt.Sprintf("%s/%s", pd.PackageWebLink(), url.PathEscape(pd.Version.LowerVersion)) diff --git a/routers/web/web.go b/routers/web/web.go index 4956f4b1ed..ac97c44ea6 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1072,11 +1072,6 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Get("", user.ListPackages) m.Group("/{type}/{name}", func() { m.Get("", user.RedirectToLastVersion) - // use `~` as a separator; otherwise it might clash with a user package named `-` or `settings` - m.Group("/~/settings", func() { - m.Get("", user.PackageSettings) - m.Post("", web.Bind(forms.PackageSettingForm{}), user.PackageSettingsPost) - }, reqPackageAccess(perm.AccessModeWrite)) m.Get("/versions", user.ListPackageVersions) m.Group("/{version}", func() { m.Get("", user.ViewPackageVersion) @@ -1085,6 +1080,10 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Get("/files/{fileid}", user.DownloadPackageFile) }) }) + m.Group("/packages-settings/{type}/{name}", func() { + m.Get("", user.PackageSettings) + m.Post("", web.Bind(forms.PackageSettingForm{}), user.PackageSettingsPost) + }, reqPackageAccess(perm.AccessModeWrite)) }, context.PackageAssignment(), reqPackageAccess(perm.AccessModeRead)) } diff --git a/templates/package/shared/view.tmpl b/templates/package/shared/view.tmpl index e2d385edc5..28b16879d3 100644 --- a/templates/package/shared/view.tmpl +++ b/templates/package/shared/view.tmpl @@ -99,7 +99,7 @@
{{svg "octicon-issue-opened"}} {{ctx.Locale.Tr "repo.issues"}}
{{end}} {{if .CanWritePackages}} -
{{svg "octicon-tools"}} {{ctx.Locale.Tr "repo.settings"}}
+
{{svg "octicon-tools"}} {{ctx.Locale.Tr "repo.settings"}}
{{svg "octicon-trash"}} {{ctx.Locale.Tr "packages.settings.delete"}} From d9834bee25d1f09281673e0bc9c10c0932072bb1 Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Wed, 1 Apr 2026 20:18:48 +0200 Subject: [PATCH 7/8] add notifications and return error on bad enum --- models/packages/descriptor.go | 10 ++++++++++ models/packages/package_property.go | 3 +++ routers/web/user/package.go | 2 +- services/packages/packages.go | 15 +++++++++++++-- 4 files changed, 27 insertions(+), 3 deletions(-) diff --git a/models/packages/descriptor.go b/models/packages/descriptor.go index be20465da0..a5e647981e 100644 --- a/models/packages/descriptor.go +++ b/models/packages/descriptor.go @@ -9,6 +9,7 @@ import ( "fmt" "net/url" + "code.gitea.io/gitea/models/db" repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/cache" @@ -272,6 +273,15 @@ func GetPackageDescriptors(ctx context.Context, pvs []*PackageVersion) ([]*Packa 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) { pds := make([]*PackageDescriptor, 0, len(pvs)) for _, pv := range pvs { diff --git a/models/packages/package_property.go b/models/packages/package_property.go index e61967c85d..c297fd8901 100644 --- a/models/packages/package_property.go +++ b/models/packages/package_property.go @@ -5,6 +5,7 @@ package packages import ( "context" + "errors" "code.gitea.io/gitea/models/db" @@ -111,6 +112,8 @@ func DeletePropertiesByPackageID(ctx context.Context, refType PropertyType, pack 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) diff --git a/routers/web/user/package.go b/routers/web/user/package.go index 5eeb989899..da206a4105 100644 --- a/routers/web/user/package.go +++ b/routers/web/user/package.go @@ -493,7 +493,7 @@ func packageSettingsPostActionLink(ctx *context.Context, form *forms.PackageSett func packageSettingsPostActionDelete(ctx *context.Context) { pd := ctx.Package.Descriptor - if err := packages_service.RemovePackage(ctx, pd.Package); err != nil { + if err := packages_service.RemovePackage(ctx, ctx.Doer, pd.Package); err != nil { log.Error("Error deleting package: %v", err) ctx.Flash.Error(ctx.Tr("packages.settings.delete.error")) } else { diff --git a/services/packages/packages.go b/services/packages/packages.go index 16ec7a3598..7f244d26d3 100644 --- a/services/packages/packages.go +++ b/services/packages/packages.go @@ -625,8 +625,12 @@ func OpenBlobForDownload(ctx context.Context, pf *packages_model.PackageFile, pb } // RemovePackage deletes the package and all its versions -func RemovePackage(ctx context.Context, p *packages_model.Package) error { - return db.WithTx(ctx, func(ctx context.Context) error { +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 + } + err = db.WithTx(ctx, func(ctx context.Context) error { err := packages_model.DeletePropertiesByPackageID(ctx, packages_model.PropertyTypePackage, p.ID) if err != nil { return err @@ -650,6 +654,13 @@ func RemovePackage(ctx context.Context, p *packages_model.Package) error { 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 From 55c66356c1645ddb869bd9fc712a759922950526 Mon Sep 17 00:00:00 2001 From: TheFox0x7 Date: Fri, 3 Apr 2026 10:29:02 +0200 Subject: [PATCH 8/8] add api endpoint and test co-authored-by: gemini3-flash --- routers/api/v1/api.go | 3 +- routers/api/v1/packages/package.go | 37 ++++++- templates/swagger/v1_json.tmpl | 42 +++++++- tests/integration/api_packages_test.go | 44 ++++++++ tests/integration/packages_service_test.go | 118 +++++++++++++++++++++ 5 files changed, 240 insertions(+), 4 deletions(-) create mode 100644 tests/integration/packages_service_test.go diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index ea595407d1..ef71a40a67 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1580,10 +1580,11 @@ func Routes() *web.Router { m.Group("/packages/{username}", func() { m.Group("/{type}/{name}", func() { m.Get("/", packages.ListPackageVersions) + m.Delete("", reqPackageAccess(perm.AccessModeWrite), packages.DeletePackage) m.Group("/{version}", func() { m.Get("", packages.GetPackage) - m.Delete("", reqPackageAccess(perm.AccessModeWrite), packages.DeletePackage) + m.Delete("", reqPackageAccess(perm.AccessModeWrite), packages.DeletePackageVersion) m.Get("/files", packages.ListPackageFiles) }) diff --git a/routers/api/v1/packages/package.go b/routers/api/v1/packages/package.go index cee0daccae..376867ab82 100644 --- a/routers/api/v1/packages/package.go +++ b/routers/api/v1/packages/package.go @@ -118,7 +118,7 @@ func GetPackage(ctx *context.APIContext) { // DeletePackage deletes a package 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 // parameters: @@ -137,6 +137,41 @@ func DeletePackage(ctx *context.APIContext) { // description: name of the package // type: string // 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 // in: path // description: version of the package diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index adc6c18175..865804ffd9 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -3914,6 +3914,44 @@ "$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": { @@ -4099,8 +4137,8 @@ "tags": [ "package" ], - "summary": "Delete a package", - "operationId": "deletePackage", + "summary": "Delete a package version", + "operationId": "deletePackageVersion", "parameters": [ { "type": "string", diff --git a/tests/integration/api_packages_test.go b/tests/integration/api_packages_test.go index 4fbb31b11f..913ce1bae2 100644 --- a/tests/integration/api_packages_test.go +++ b/tests/integration/api_packages_test.go @@ -85,6 +85,50 @@ func TestPackageAPI(t *testing.T) { 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) { defer tests.PrintCurrentTest(t)() diff --git a/tests/integration/packages_service_test.go b/tests/integration/packages_service_test.go new file mode 100644 index 0000000000..39fe2017d5 --- /dev/null +++ b/tests/integration/packages_service_test.go @@ -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 + p2_after, err := packages_model.GetPackageByID(t.Context(), p2.ID) + assert.NoError(t, err) + assert.NotNil(t, p2_after) + + pv2_after, err := packages_model.GetVersionByID(t.Context(), pv2.ID) + assert.NoError(t, err) + assert.NotNil(t, pv2_after) + + pf2_after, err := packages_model.GetFileForVersionByID(t.Context(), pv2.ID, pf2.ID) + assert.NoError(t, err) + assert.NotNil(t, pf2_after) + + checkProps(p2, pv2, pf2, true) +}