2
0
Files
gitcaddy-server/routers/web/user/package.go
logikonline 0f9728006a
Some checks failed
Build and Release / Create Release (push) Successful in 0s
Build and Release / Unit Tests (push) Successful in 3m22s
Build and Release / Integration Tests (PostgreSQL) (push) Successful in 5m21s
Build and Release / Lint (push) Successful in 5m38s
Build and Release / Build Binaries (amd64, linux, linux-latest) (push) Successful in 2m59s
Build and Release / Build Binaries (amd64, windows, windows-latest) (push) Successful in 8h5m19s
Build and Release / Build Binaries (amd64, darwin, macos) (push) Successful in 7m10s
Build and Release / Build Binaries (arm64, darwin, macos) (push) Has been cancelled
Build and Release / Build Binary (linux/arm64) (push) Has been cancelled
feat(ci): add blog search/filtering and package privacy
Adds keyword search and tag filtering to repository blog list with GetRepoTopTags for popular tags display. Implements user-level package privacy setting (KeepPackagesPrivate) to hide packages from profile page. Updates blog UI with search box, tag cloud, and clear filters button. Adds subscription CTA buttons and active subscription indicators.
2026-02-03 09:47:08 -05:00

635 lines
19 KiB
Go

// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package user
import (
gocontext "context"
"errors"
"net/http"
"net/url"
"code.gitcaddy.com/server/v3/models/db"
org_model "code.gitcaddy.com/server/v3/models/organization"
packages_model "code.gitcaddy.com/server/v3/models/packages"
container_model "code.gitcaddy.com/server/v3/models/packages/container"
"code.gitcaddy.com/server/v3/models/perm"
access_model "code.gitcaddy.com/server/v3/models/perm/access"
repo_model "code.gitcaddy.com/server/v3/models/repo"
"code.gitcaddy.com/server/v3/models/unit"
user_model "code.gitcaddy.com/server/v3/models/user"
"code.gitcaddy.com/server/v3/modules/container"
"code.gitcaddy.com/server/v3/modules/httplib"
"code.gitcaddy.com/server/v3/modules/log"
"code.gitcaddy.com/server/v3/modules/optional"
alpine_module "code.gitcaddy.com/server/v3/modules/packages/alpine"
arch_module "code.gitcaddy.com/server/v3/modules/packages/arch"
container_module "code.gitcaddy.com/server/v3/modules/packages/container"
debian_module "code.gitcaddy.com/server/v3/modules/packages/debian"
rpm_module "code.gitcaddy.com/server/v3/modules/packages/rpm"
"code.gitcaddy.com/server/v3/modules/setting"
"code.gitcaddy.com/server/v3/modules/templates"
"code.gitcaddy.com/server/v3/modules/util"
"code.gitcaddy.com/server/v3/modules/web"
packages_helper "code.gitcaddy.com/server/v3/routers/api/packages/helper"
shared_user "code.gitcaddy.com/server/v3/routers/web/shared/user"
"code.gitcaddy.com/server/v3/services/context"
"code.gitcaddy.com/server/v3/services/forms"
packages_service "code.gitcaddy.com/server/v3/services/packages"
container_service "code.gitcaddy.com/server/v3/services/packages/container"
)
const (
tplPackagesList templates.TplName = "user/overview/packages"
tplPackagesView templates.TplName = "package/view"
tplPackageVersionList templates.TplName = "user/overview/package_versions"
tplPackagesSettings templates.TplName = "package/settings"
)
// canViewPrivatePackages checks if the viewer can see private packages of the owner
func canViewPrivatePackages(ctx gocontext.Context, owner, viewer *user_model.User) bool {
// Not logged in - can't see private packages
if viewer == nil || viewer.IsGhost() {
return false
}
// Site admin can see all
if viewer.IsAdmin {
return true
}
// Owner can see their own private packages
if owner.ID == viewer.ID {
return true
}
// For organizations, check if viewer has read access to packages (is a member)
if owner.IsOrganization() {
org := org_model.OrgFromUser(owner)
teams, err := org_model.GetUserOrgTeams(ctx, org.ID, viewer.ID)
if err != nil {
return false
}
for _, t := range teams {
if t.UnitAccessMode(ctx, unit.TypePackages) >= perm.AccessModeRead {
return true
}
}
}
return false
}
// ListPackages displays a list of all packages of the context user
func ListPackages(ctx *context.Context) {
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
ctx.ServerError("RenderUserOrgHeader", err)
return
}
// Check if packages are private for this user
if ctx.ContextUser.KeepPackagesPrivate {
isOwnerOrAdmin := ctx.Doer != nil && (ctx.Doer.IsAdmin || ctx.Doer.ID == ctx.ContextUser.ID)
if !isOwnerOrAdmin {
ctx.Data["PackagesPrivate"] = true
ctx.HTML(200, tplPackagesList)
return
}
}
page := max(ctx.FormInt("page"), 1)
query := ctx.FormTrim("q")
packageType := ctx.FormTrim("type")
// Check if viewer can see private packages
canSeePrivate := canViewPrivatePackages(ctx, ctx.ContextUser, ctx.Doer)
searchOpts := &packages_model.PackageSearchOptions{
Paginator: &db.ListOptions{
PageSize: setting.UI.PackagesPagingNum,
Page: page,
},
OwnerID: ctx.ContextUser.ID,
Type: packages_model.Type(packageType),
Name: packages_model.SearchValue{Value: query},
IsInternal: optional.Some(false),
}
// If user can't see private packages, filter them out
if !canSeePrivate {
searchOpts.IsPrivate = optional.Some(false)
}
pvs, total, err := packages_model.SearchLatestVersions(ctx, searchOpts)
if err != nil {
ctx.ServerError("SearchLatestVersions", err)
return
}
pds, err := packages_model.GetPackageDescriptors(ctx, pvs)
if err != nil {
ctx.ServerError("GetPackageDescriptors", err)
return
}
repositoryAccessMap := make(map[int64]bool)
for _, pd := range pds {
if pd.Repository == nil {
continue
}
if _, has := repositoryAccessMap[pd.Repository.ID]; has {
continue
}
permission, err := access_model.GetUserRepoPermission(ctx, pd.Repository, ctx.Doer)
if err != nil {
ctx.ServerError("GetUserRepoPermission", err)
return
}
repositoryAccessMap[pd.Repository.ID] = permission.HasAnyUnitAccess()
}
hasPackages, err := packages_model.HasOwnerPackages(ctx, ctx.ContextUser.ID)
if err != nil {
ctx.ServerError("HasOwnerPackages", err)
return
}
ctx.Data["Title"] = ctx.Tr("packages.title")
ctx.Data["IsPackagesPage"] = true
ctx.Data["Query"] = query
ctx.Data["PackageType"] = packageType
ctx.Data["AvailableTypes"] = packages_model.TypeList
ctx.Data["HasPackages"] = hasPackages
ctx.Data["PackageDescriptors"] = pds
ctx.Data["Total"] = total
ctx.Data["RepositoryAccessMap"] = repositoryAccessMap
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
ctx.ServerError("RenderUserOrgHeader", err)
return
}
// TODO: context/org -> HandleOrgAssignment() can not be used
if ctx.ContextUser.IsOrganization() {
org := org_model.OrgFromUser(ctx.ContextUser)
ctx.Data["Org"] = org
ctx.Data["OrgLink"] = ctx.ContextUser.OrganisationLink()
if ctx.Doer != nil {
ctx.Data["IsOrganizationMember"], _ = org_model.IsOrganizationMember(ctx, org.ID, ctx.Doer.ID)
ctx.Data["IsOrganizationOwner"], _ = org_model.IsOrganizationOwner(ctx, org.ID, ctx.Doer.ID)
} else {
ctx.Data["IsOrganizationMember"] = false
ctx.Data["IsOrganizationOwner"] = false
}
}
pager := context.NewPagination(int(total), setting.UI.PackagesPagingNum, page, 5)
pager.AddParamFromRequest(ctx.Req)
ctx.Data["Page"] = pager
ctx.HTML(http.StatusOK, tplPackagesList)
}
// RedirectToLastVersion redirects to the latest package version
func RedirectToLastVersion(ctx *context.Context) {
p, err := packages_model.GetPackageByName(ctx, ctx.Package.Owner.ID, packages_model.Type(ctx.PathParam("type")), ctx.PathParam("name"))
if err != nil {
if err == packages_model.ErrPackageNotExist {
ctx.NotFound(err)
} else {
ctx.ServerError("GetPackageByName", err)
}
return
}
pvs, _, err := packages_model.SearchLatestVersions(ctx, &packages_model.PackageSearchOptions{
PackageID: p.ID,
IsInternal: optional.Some(false),
})
if err != nil {
ctx.ServerError("GetPackageByName", err)
return
}
if len(pvs) == 0 {
ctx.NotFound(err)
return
}
pd, err := packages_model.GetPackageDescriptor(ctx, pvs[0])
if err != nil {
ctx.ServerError("GetPackageDescriptor", err)
return
}
ctx.Redirect(pd.VersionWebLink())
}
func viewPackageContainerImage(ctx gocontext.Context, pd *packages_model.PackageDescriptor, digest string) (*container_module.Metadata, error) {
manifestBlob, err := container_model.GetContainerBlob(ctx, &container_model.BlobSearchOptions{
OwnerID: pd.Owner.ID,
Image: pd.Package.LowerName,
Digest: digest,
})
if err != nil {
return nil, err
}
manifestReader, err := packages_service.OpenBlobStream(manifestBlob.Blob)
if err != nil {
return nil, err
}
defer manifestReader.Close()
_, _, metadata, err := container_service.ParseManifestMetadata(ctx, manifestReader, pd.Owner.ID, pd.Package.LowerName)
return metadata, err
}
// ViewPackageVersion displays a single package version
func ViewPackageVersion(ctx *context.Context) {
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
ctx.ServerError("RenderUserOrgHeader", err)
return
}
versionSub := ctx.PathParam("version_sub")
pd := ctx.Package.Descriptor
ctx.Data["Title"] = pd.Package.Name
ctx.Data["IsPackagesPage"] = true
ctx.Data["PackageDescriptor"] = pd
registryHostURL, err := url.Parse(httplib.GuessCurrentHostURL(ctx))
if err != nil {
registryHostURL, _ = url.Parse(setting.AppURL)
}
ctx.Data["PackageRegistryHost"] = registryHostURL.Host
switch pd.Package.Type {
case packages_model.TypeAlpine:
branches := make(container.Set[string])
repositories := make(container.Set[string])
architectures := make(container.Set[string])
for _, f := range pd.Files {
for _, pp := range f.Properties {
switch pp.Name {
case alpine_module.PropertyBranch:
branches.Add(pp.Value)
case alpine_module.PropertyRepository:
repositories.Add(pp.Value)
case alpine_module.PropertyArchitecture:
architectures.Add(pp.Value)
}
}
}
ctx.Data["Branches"] = util.Sorted(branches.Values())
ctx.Data["Repositories"] = util.Sorted(repositories.Values())
ctx.Data["Architectures"] = util.Sorted(architectures.Values())
case packages_model.TypeArch:
repositories := make(container.Set[string])
architectures := make(container.Set[string])
for _, f := range pd.Files {
for _, pp := range f.Properties {
switch pp.Name {
case arch_module.PropertyRepository:
repositories.Add(pp.Value)
case arch_module.PropertyArchitecture:
architectures.Add(pp.Value)
}
}
}
ctx.Data["Repositories"] = util.Sorted(repositories.Values())
ctx.Data["Architectures"] = util.Sorted(architectures.Values())
case packages_model.TypeDebian:
distributions := make(container.Set[string])
components := make(container.Set[string])
architectures := make(container.Set[string])
for _, f := range pd.Files {
for _, pp := range f.Properties {
switch pp.Name {
case debian_module.PropertyDistribution:
distributions.Add(pp.Value)
case debian_module.PropertyComponent:
components.Add(pp.Value)
case debian_module.PropertyArchitecture:
architectures.Add(pp.Value)
}
}
}
ctx.Data["Distributions"] = util.Sorted(distributions.Values())
ctx.Data["Components"] = util.Sorted(components.Values())
ctx.Data["Architectures"] = util.Sorted(architectures.Values())
case packages_model.TypeRpm:
groups := make(container.Set[string])
architectures := make(container.Set[string])
for _, f := range pd.Files {
for _, pp := range f.Properties {
switch pp.Name {
case rpm_module.PropertyGroup:
groups.Add(pp.Value)
case rpm_module.PropertyArchitecture:
architectures.Add(pp.Value)
}
}
}
ctx.Data["Groups"] = util.Sorted(groups.Values())
ctx.Data["Architectures"] = util.Sorted(architectures.Values())
case packages_model.TypeContainer:
imageMetadata := pd.Metadata
if versionSub != "" {
imageMetadata, err = viewPackageContainerImage(ctx, pd, versionSub)
if errors.Is(err, util.ErrNotExist) {
ctx.NotFound(nil)
return
} else if err != nil {
ctx.ServerError("viewPackageContainerImage", err)
return
}
}
ctx.Data["ContainerImageMetadata"] = imageMetadata
}
var pvs []*packages_model.PackageVersion
var pvsTotal int64
if pd.Package.Type == packages_model.TypeContainer {
pvs, pvsTotal, err = container_model.SearchImageTags(ctx, &container_model.ImageTagsSearchOptions{
Paginator: db.NewAbsoluteListOptions(0, 5),
PackageID: pd.Package.ID,
IsTagged: true,
})
} else {
pvs, pvsTotal, err = packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{
Paginator: db.NewAbsoluteListOptions(0, 5),
PackageID: pd.Package.ID,
IsInternal: optional.Some(false),
})
}
if err != nil {
ctx.ServerError("", err)
return
}
ctx.Data["LatestVersions"] = pvs
ctx.Data["TotalVersionCount"] = pvsTotal
ctx.Data["CanWritePackages"] = ctx.Package.AccessMode >= perm.AccessModeWrite || ctx.IsUserSiteAdmin()
hasRepositoryAccess := false
if pd.Repository != nil {
permission, err := access_model.GetUserRepoPermission(ctx, pd.Repository, ctx.Doer)
if err != nil {
ctx.ServerError("GetUserRepoPermission", err)
return
}
hasRepositoryAccess = permission.HasAnyUnitAccess()
}
ctx.Data["HasRepositoryAccess"] = hasRepositoryAccess
ctx.HTML(http.StatusOK, tplPackagesView)
}
// ListPackageVersions lists all versions of a package
func ListPackageVersions(ctx *context.Context) {
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
ctx.ServerError("RenderUserOrgHeader", err)
return
}
p, err := packages_model.GetPackageByName(ctx, ctx.Package.Owner.ID, packages_model.Type(ctx.PathParam("type")), ctx.PathParam("name"))
if err != nil {
if err == packages_model.ErrPackageNotExist {
ctx.NotFound(err)
} else {
ctx.ServerError("GetPackageByName", err)
}
return
}
page := max(ctx.FormInt("page"), 1)
pagination := &db.ListOptions{
PageSize: setting.UI.PackagesPagingNum,
Page: page,
}
query := ctx.FormTrim("q")
sort := ctx.FormTrim("sort")
ctx.Data["Title"] = ctx.Tr("packages.title")
ctx.Data["IsPackagesPage"] = true
ctx.Data["PackageDescriptor"] = &packages_model.PackageDescriptor{
Package: p,
Owner: ctx.Package.Owner,
}
ctx.Data["Query"] = query
ctx.Data["Sort"] = sort
var (
total int64
pvs []*packages_model.PackageVersion
)
switch p.Type {
case packages_model.TypeContainer:
tagged := ctx.FormTrim("tagged")
ctx.Data["Tagged"] = tagged
pvs, total, err = container_model.SearchImageTags(ctx, &container_model.ImageTagsSearchOptions{
Paginator: pagination,
PackageID: p.ID,
Query: query,
IsTagged: tagged == "" || tagged == "tagged",
Sort: sort,
})
if err != nil {
ctx.ServerError("SearchImageTags", err)
return
}
default:
pvs, total, err = packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{
Paginator: pagination,
PackageID: p.ID,
Version: packages_model.SearchValue{
ExactMatch: false,
Value: query,
},
IsInternal: optional.Some(false),
Sort: sort,
})
if err != nil {
ctx.ServerError("SearchVersions", err)
return
}
}
ctx.Data["PackageDescriptors"], err = packages_model.GetPackageDescriptors(ctx, pvs)
if err != nil {
ctx.ServerError("GetPackageDescriptors", err)
return
}
ctx.Data["Total"] = total
pager := context.NewPagination(int(total), setting.UI.PackagesPagingNum, page, 5)
pager.AddParamFromRequest(ctx.Req)
ctx.Data["Page"] = pager
ctx.HTML(http.StatusOK, tplPackageVersionList)
}
// PackageSettings displays the package settings page
func PackageSettings(ctx *context.Context) {
pd := ctx.Package.Descriptor
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
ctx.ServerError("RenderUserOrgHeader", err)
return
}
ctx.Data["Title"] = pd.Package.Name
ctx.Data["IsPackagesPage"] = true
ctx.Data["PackageDescriptor"] = pd
ctx.Data["CanWritePackages"] = ctx.Package.AccessMode >= perm.AccessModeWrite || ctx.IsUserSiteAdmin()
if pd.Package.RepoID > 0 {
repo, err := repo_model.GetRepositoryByID(ctx, pd.Package.RepoID)
if err != nil {
ctx.ServerError("GetRepositoryByID", err)
return
}
ctx.Data["LinkedRepoName"] = repo.Name
}
ctx.HTML(http.StatusOK, tplPackagesSettings)
}
// PackageSettingsPost updates the package settings
func PackageSettingsPost(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.PackageSettingForm)
switch form.Action {
case "link":
packageSettingsPostActionLink(ctx, form)
case "delete":
packageSettingsPostActionDelete(ctx)
case "global":
packageSettingsPostActionGlobal(ctx)
case "visibility":
packageSettingsPostActionVisibility(ctx)
default:
ctx.NotFound(nil)
}
}
func packageSettingsPostActionLink(ctx *context.Context, form *forms.PackageSettingForm) {
pd := ctx.Package.Descriptor
if form.RepoName == "" { // remove the link
if err := packages_model.SetRepositoryLink(ctx, pd.Package.ID, 0); err != nil {
ctx.JSONError(ctx.Tr("packages.settings.unlink.error"))
return
}
ctx.Flash.Success(ctx.Tr("packages.settings.unlink.success"))
ctx.JSONRedirect("")
return
}
repo, err := repo_model.GetRepositoryByName(ctx, pd.Owner.ID, form.RepoName)
if err != nil {
if repo_model.IsErrRepoNotExist(err) {
ctx.JSONError(ctx.Tr("packages.settings.link.repo_not_found", form.RepoName))
} else {
ctx.ServerError("GetRepositoryByOwnerAndName", err)
}
return
}
if err := packages_model.SetRepositoryLink(ctx, pd.Package.ID, repo.ID); err != nil {
ctx.JSONError(ctx.Tr("packages.settings.link.error"))
return
}
ctx.Flash.Success(ctx.Tr("packages.settings.link.success"))
ctx.JSONRedirect("")
}
func packageSettingsPostActionDelete(ctx *context.Context) {
err := packages_service.RemovePackageVersion(ctx, ctx.Doer, ctx.Package.Descriptor.Version)
if 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"))
}
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()
}
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")
}
func packageSettingsPostActionVisibility(ctx *context.Context) {
pd := ctx.Package.Descriptor
// Toggle the visibility
newIsPrivate := !pd.Package.IsPrivate
if err := packages_model.SetPackageIsPrivate(ctx, pd.Package.ID, newIsPrivate); err != nil {
ctx.Flash.Error(ctx.Tr("packages.settings.visibility.error"))
ctx.Redirect(ctx.Package.Descriptor.VersionWebLink() + "/settings")
return
}
if newIsPrivate {
ctx.Flash.Success(ctx.Tr("packages.settings.visibility.private.success"))
} else {
ctx.Flash.Success(ctx.Tr("packages.settings.visibility.public.success"))
}
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"))
if err != nil {
if err == packages_model.ErrPackageFileNotExist {
ctx.NotFound(err)
} else {
ctx.ServerError("GetFileForVersionByID", err)
}
return
}
s, u, _, err := packages_service.OpenFileForDownload(ctx, pf, ctx.Req.Method)
if err != nil {
ctx.ServerError("OpenFileForDownload", err)
return
}
packages_helper.ServePackageFile(ctx, s, u, pf)
}