Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions PACKAGES_EXPLORE_FEATURE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Packages Explore Feature

## Overview
This feature adds a new "Packages" tab to the Explore page, allowing users to discover and browse packages that they have access to across the Gitea instance.

## Features

### User-Facing Features
1. **Packages Tab in Explore**: A new tab in the explore navigation that displays all accessible packages
2. **Search and Filter**: Users can search packages by name and filter by package type (npm, Maven, Docker, etc.)
3. **Permission-Based Access**: Only shows packages that the user has permission to view based on:
- Public user packages (visible to everyone)
- Limited visibility user packages (visible to logged-in users)
- Organization packages (visible based on org visibility and membership)
- Private packages (only visible to the owner)

### Admin Features
1. **Toggle Control**: Admins can enable/disable the packages explore page via `app.ini` configuration
2. **Configuration Setting**: `[service.explore]` section with `DISABLE_PACKAGES_PAGE` option

## Configuration

Add the following to your `app.ini` file under the `[service.explore]` section:

```ini
[service.explore]
; Disable the packages explore page
DISABLE_PACKAGES_PAGE = false
```

Set to `true` to hide the packages tab from the explore page.

## Implementation Details

### Backend Changes
- **New Handler**: `routers/web/explore/packages.go` - Handles package listing with permission filtering
- **Configuration**: `modules/setting/service.go` - Added `DisablePackagesPage` setting
- **Route**: Added `/explore/packages` route in `routers/web/web.go`

### Frontend Changes
- **Template**: `templates/explore/packages.tmpl` - Displays package list with search/filter
- **Navigation**: Updated `templates/explore/navbar.tmpl` to include packages tab

### Permission Logic
The feature implements proper access control by:
1. Fetching packages from the database
2. Checking each package's owner visibility:
- For user-owned packages: Check user visibility (public/limited/private)
- For org-owned packages: Check org visibility and user membership
3. Filtering results to only show accessible packages
4. Respecting the `DISABLE_PACKAGES_PAGE` configuration setting

## Security Considerations
- Anonymous users only see packages from public users/organizations
- Logged-in users see packages from public and limited visibility users, plus organizations they're members of
- Private user packages are only visible to the owner
- The feature requires packages to be enabled (`[packages] ENABLED = true`)

## Testing
To test the feature:
1. Enable packages in your Gitea instance
2. Create packages under different users/organizations with varying visibility settings
3. Access `/explore/packages` as different user types (anonymous, logged-in, org member)
4. Verify that only appropriate packages are displayed
5. Test the admin toggle by setting `DISABLE_PACKAGES_PAGE = true` and verifying the tab disappears

## Future Enhancements
Potential improvements for future versions:
- Add sorting options (by date, name, downloads)
- Implement more efficient database-level permission filtering
- Add package statistics and trending packages
- Support for package categories/tags
3 changes: 3 additions & 0 deletions custom/conf/app.example.ini
Original file line number Diff line number Diff line change
Expand Up @@ -946,6 +946,9 @@ LEVEL = Info
;;
;; Disable the code explore page.
;DISABLE_CODE_PAGE = false
;;
;; Disable the packages explore page.
;DISABLE_PACKAGES_PAGE = false

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
Expand Down
1 change: 1 addition & 0 deletions modules/setting/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ var Service = struct {
DisableUsersPage bool `ini:"DISABLE_USERS_PAGE"`
DisableOrganizationsPage bool `ini:"DISABLE_ORGANIZATIONS_PAGE"`
DisableCodePage bool `ini:"DISABLE_CODE_PAGE"`
DisablePackagesPage bool `ini:"DISABLE_PACKAGES_PAGE"`
} `ini:"service.explore"`

QoS struct {
Expand Down
2 changes: 2 additions & 0 deletions routers/web/explore/code.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ func Code(ctx *context.Context) {

ctx.Data["UsersPageIsDisabled"] = setting.Service.Explore.DisableUsersPage
ctx.Data["OrganizationsPageIsDisabled"] = setting.Service.Explore.DisableOrganizationsPage
ctx.Data["PackagesPageIsDisabled"] = setting.Service.Explore.DisablePackagesPage
ctx.Data["PackagesEnabled"] = setting.Packages.Enabled
ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled
ctx.Data["Title"] = ctx.Tr("explore")
ctx.Data["PageIsExplore"] = true
Expand Down
2 changes: 2 additions & 0 deletions routers/web/explore/org.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ func Organizations(ctx *context.Context) {

ctx.Data["UsersPageIsDisabled"] = setting.Service.Explore.DisableUsersPage
ctx.Data["CodePageIsDisabled"] = setting.Service.Explore.DisableCodePage
ctx.Data["PackagesPageIsDisabled"] = setting.Service.Explore.DisablePackagesPage
ctx.Data["PackagesEnabled"] = setting.Packages.Enabled
ctx.Data["Title"] = ctx.Tr("explore")
ctx.Data["PageIsExplore"] = true
ctx.Data["PageIsExploreOrganizations"] = true
Expand Down
124 changes: 124 additions & 0 deletions routers/web/explore/packages.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package explore

import (
"net/http"

"code.gitea.io/gitea/models/db"
org_model "code.gitea.io/gitea/models/organization"
packages_model "code.gitea.io/gitea/models/packages"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/services/context"
)

const (
tplExplorePackages templates.TplName = "explore/packages"
)

// Packages render explore packages page
func Packages(ctx *context.Context) {
ctx.Data["UsersPageIsDisabled"] = setting.Service.Explore.DisableUsersPage
ctx.Data["OrganizationsPageIsDisabled"] = setting.Service.Explore.DisableOrganizationsPage
ctx.Data["CodePageIsDisabled"] = setting.Service.Explore.DisableCodePage
ctx.Data["PackagesPageIsDisabled"] = setting.Service.Explore.DisablePackagesPage
ctx.Data["PackagesEnabled"] = setting.Packages.Enabled
ctx.Data["Title"] = ctx.Tr("explore")
ctx.Data["PageIsExplore"] = true
ctx.Data["PageIsExplorePackages"] = true

page := ctx.FormInt("page")
if page <= 0 {
page = 1
}

query := ctx.FormTrim("q")
packageType := ctx.FormTrim("type")

ctx.Data["Query"] = query
ctx.Data["PackageType"] = packageType
ctx.Data["AvailableTypes"] = packages_model.TypeList

// Get all packages matching the search criteria
pvs, total, err := packages_model.SearchLatestVersions(ctx, &packages_model.PackageSearchOptions{
Paginator: &db.ListOptions{
PageSize: setting.UI.PackagesPagingNum * 3, // Get more to account for filtering
Page: page,
},
Type: packages_model.Type(packageType),
Name: packages_model.SearchValue{Value: query},
IsInternal: optional.Some(false),
})
if err != nil {
ctx.ServerError("SearchLatestVersions", err)
return
}

// Filter packages based on user permissions
accessiblePVs := make([]*packages_model.PackageVersion, 0, len(pvs))
for _, pv := range pvs {
pkg, err := packages_model.GetPackageByID(ctx, pv.PackageID)
if err != nil {
ctx.ServerError("GetPackageByID", err)
return
}

owner, err := user_model.GetUserByID(ctx, pkg.OwnerID)
if err != nil {
ctx.ServerError("GetUserByID", err)
return
}

// Check if user has access to this package based on owner visibility
hasAccess := false

Check failure on line 78 in routers/web/explore/packages.go

View workflow job for this annotation

GitHub Actions / lint-go-windows

assigned to hasAccess, but reassigned without using the value (wastedassign)

Check failure on line 78 in routers/web/explore/packages.go

View workflow job for this annotation

GitHub Actions / lint-backend

assigned to hasAccess, but reassigned without using the value (wastedassign)

Check failure on line 78 in routers/web/explore/packages.go

View workflow job for this annotation

GitHub Actions / lint-go-gogit

assigned to hasAccess, but reassigned without using the value (wastedassign)
if owner.IsOrganization() {
// For organizations, check if user can see the org
if ctx.Doer != nil {
isMember, err := org_model.IsOrganizationMember(ctx, owner.ID, ctx.Doer.ID)
if err != nil {
ctx.ServerError("IsOrganizationMember", err)
return
}
hasAccess = isMember || owner.Visibility == structs.VisibleTypePublic
} else {
hasAccess = owner.Visibility == structs.VisibleTypePublic
}
} else {
// For users, check visibility
if ctx.Doer != nil {
hasAccess = owner.Visibility == structs.VisibleTypePublic ||
owner.Visibility == structs.VisibleTypeLimited ||
owner.ID == ctx.Doer.ID
} else {
hasAccess = owner.Visibility == structs.VisibleTypePublic
}
}

if hasAccess {
accessiblePVs = append(accessiblePVs, pv)
if len(accessiblePVs) >= setting.UI.PackagesPagingNum {
break
}
}
}

pds, err := packages_model.GetPackageDescriptors(ctx, accessiblePVs)
if err != nil {
ctx.ServerError("GetPackageDescriptors", err)
return
}

ctx.Data["Total"] = int64(len(accessiblePVs))
ctx.Data["PackageDescriptors"] = pds

pager := context.NewPagination(int(total), setting.UI.PackagesPagingNum, page, 5)
pager.AddParamFromRequest(ctx.Req)
ctx.Data["Page"] = pager

ctx.HTML(http.StatusOK, tplExplorePackages)
}
2 changes: 2 additions & 0 deletions routers/web/explore/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,8 @@ func Repos(ctx *context.Context) {
ctx.Data["UsersPageIsDisabled"] = setting.Service.Explore.DisableUsersPage
ctx.Data["OrganizationsPageIsDisabled"] = setting.Service.Explore.DisableOrganizationsPage
ctx.Data["CodePageIsDisabled"] = setting.Service.Explore.DisableCodePage
ctx.Data["PackagesPageIsDisabled"] = setting.Service.Explore.DisablePackagesPage
ctx.Data["PackagesEnabled"] = setting.Packages.Enabled
ctx.Data["Title"] = ctx.Tr("explore")
ctx.Data["PageIsExplore"] = true
ctx.Data["ShowRepoOwnerOnList"] = true
Expand Down
2 changes: 2 additions & 0 deletions routers/web/explore/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,8 @@ func Users(ctx *context.Context) {
}
ctx.Data["OrganizationsPageIsDisabled"] = setting.Service.Explore.DisableOrganizationsPage
ctx.Data["CodePageIsDisabled"] = setting.Service.Explore.DisableCodePage
ctx.Data["PackagesPageIsDisabled"] = setting.Service.Explore.DisablePackagesPage
ctx.Data["PackagesEnabled"] = setting.Packages.Enabled
ctx.Data["Title"] = ctx.Tr("explore")
ctx.Data["PageIsExplore"] = true
ctx.Data["PageIsExploreUsers"] = true
Expand Down
1 change: 1 addition & 0 deletions routers/web/web.go
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,7 @@ func registerWebRoutes(m *web.Router) {
return
}
}, explore.Code)
m.Get("/packages", packagesEnabled, explore.Packages)
m.Get("/topics/search", explore.TopicSearch)
}, optExploreSignIn)

Expand Down
5 changes: 5 additions & 0 deletions templates/explore/navbar.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,10 @@
{{svg "octicon-code"}} {{ctx.Locale.Tr "explore.code"}}
</a>
{{end}}
{{if and .PackagesEnabled (not .PackagesPageIsDisabled)}}
<a class="{{if .PageIsExplorePackages}}active {{end}}item" href="{{AppSubUrl}}/explore/packages">
{{svg "octicon-package"}} {{ctx.Locale.Tr "packages.title"}}
</a>
{{end}}
</div>
</overflow-menu>
50 changes: 50 additions & 0 deletions templates/explore/packages.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
{{template "base/head" .}}
<div role="main" aria-label="{{.Title}}" class="page-content explore packages">
{{template "explore/navbar" .}}
<div class="ui container">
{{template "base/alert" .}}
<form class="ui form ignore-dirty">
<div class="ui small fluid action input">
{{template "shared/search/input" dict "Value" .Query "Placeholder" (ctx.Locale.Tr "search.package_kind")}}
<select class="ui small dropdown" name="type">
<option value="">{{ctx.Locale.Tr "packages.filter.type"}}</option>
<option value="all">{{ctx.Locale.Tr "packages.filter.type.all"}}</option>
{{range $type := .AvailableTypes}}
<option{{if eq $.PackageType $type}} selected="selected"{{end}} value="{{$type}}">{{$type.Name}}</option>
{{end}}
</select>
{{template "shared/search/button"}}
</div>
</form>
<div>
{{range .PackageDescriptors}}
<div class="flex-list">
<div class="flex-item">
<div class="flex-item-main">
<div class="flex-item-title">
<a href="{{.VersionWebLink}}">{{.Package.Name}}</a>
<span class="ui label">{{svg .Package.Type.SVGName 16}} {{.Package.Type.Name}}</span>
</div>
<div class="flex-item-body">
{{$timeStr := DateUtils.TimeSince .Version.CreatedUnix}}
{{ctx.Locale.Tr "packages.published_by" $timeStr .Creator.HomeLink .Creator.GetDisplayName}}
</div>
</div>
</div>
</div>
{{else}}
{{if eq .Total 0}}
<div class="empty-placeholder">
{{svg "octicon-package" 48}}
<h2>{{ctx.Locale.Tr "packages.empty"}}</h2>
<p>{{ctx.Locale.Tr "packages.empty.documentation" "https://docs.gitea.com/usage/packages/overview/"}}</p>
</div>
{{else}}
<p class="tw-py-4">{{ctx.Locale.Tr "packages.filter.no_result"}}</p>
{{end}}
{{end}}
{{template "base/paginate" .}}
</div>
</div>
</div>
{{template "base/footer" .}}
Loading