diff --git a/models/licenses/update_stream_config.go b/models/licenses/update_stream_config.go index 6235fd6622..d203f2276e 100644 --- a/models/licenses/update_stream_config.go +++ b/models/licenses/update_stream_config.go @@ -24,9 +24,19 @@ type UpdateStreamConfig struct { OwnerID int64 `xorm:"INDEX NOT NULL"` // org or user RepoID int64 `xorm:"INDEX NOT NULL DEFAULT 0"` // 0 = org-level default StreamMode string `xorm:"NOT NULL DEFAULT 'joomla'"` // joomla, custom - Platform string `xorm:"NOT NULL DEFAULT 'joomla'"` // joomla, dolibarr, both + Platform string `xorm:"NOT NULL DEFAULT 'joomla'"` // joomla, dolibarr, both, wordpress, prestashop, drupal LicensingEnabled bool `xorm:"NOT NULL DEFAULT false"` // master toggle for licensing system RequireKey bool `xorm:"NOT NULL DEFAULT false"` // require license key for update feed + // Extension metadata — used in update feed generation. + ExtensionName string `xorm:"TEXT"` // element identifier (e.g. pkg_mokowaas, com_mokowaas) + DisplayName string `xorm:"TEXT"` // human-readable name (e.g. "Package - MokoWaaS") + Description string `xorm:"TEXT"` // short description for update feeds + ExtensionType string `xorm:"VARCHAR(50)"` // component, module, plugin, package, template, library + Maintainer string `xorm:"TEXT"` // maintainer/author name + MaintainerURL string `xorm:"TEXT"` // maintainer website + InfoURL string `xorm:"TEXT"` // extension info/product page URL + TargetVersion string `xorm:"TEXT"` // target platform version regex (e.g. "(5|6)\..*") + PHPMinimum string `xorm:"VARCHAR(20)"` // minimum PHP version (e.g. "8.1") // CustomStreams is a JSON array of stream definitions. // Each entry: {"name":"lts","suffix":"-lts","description":"Long-term support"} CustomStreams string `xorm:"TEXT"` @@ -121,6 +131,19 @@ func GetEffectiveStreams(ctx context.Context, ownerID, repoID int64) []StreamDef return DefaultJoomlaStreams() } +// GetEffectiveConfig returns the full config for a repo: repo override → org default. +func GetEffectiveConfig(ctx context.Context, ownerID, repoID int64) *UpdateStreamConfig { + repoCfg, err := GetRepoConfig(ctx, repoID) + if err == nil && repoCfg != nil { + return repoCfg + } + orgCfg, err := GetOrgConfig(ctx, ownerID) + if err == nil && orgCfg != nil { + return orgCfg + } + return nil +} + // SaveConfig creates or updates an update stream config. func SaveConfig(ctx context.Context, cfg *UpdateStreamConfig) error { existing := new(UpdateStreamConfig) diff --git a/models/organization/org.go b/models/organization/org.go index 6c6f8d3ac9..739dfc6e46 100644 --- a/models/organization/org.go +++ b/models/organization/org.go @@ -417,8 +417,8 @@ func GetUsersWhoCanCreateOrgRepo(ctx context.Context, orgID int64) (map[int64]*u And("team_user.org_id = ?", orgID).Find(&users) } -// HasOrgOrUserVisible tells if the given user can see the given org or user -func HasOrgOrUserVisible(ctx context.Context, orgOrUser, user *user_model.User) bool { +// IsOwnerVisibleToDoer tells if the given user can see the given org or user +func IsOwnerVisibleToDoer(ctx context.Context, orgOrUser, user *user_model.User) bool { // If user is nil, it's an anonymous user/request. // The Ghost user is handled like an anonymous user. if user == nil || user.IsGhost() { @@ -446,7 +446,7 @@ func HasOrgsVisible(ctx context.Context, orgs []*Organization, user *user_model. } for _, org := range orgs { - if HasOrgOrUserVisible(ctx, org.AsUser(), user) { + if IsOwnerVisibleToDoer(ctx, org.AsUser(), user) { return true } } diff --git a/models/organization/org_test.go b/models/organization/org_test.go index cb7ecbf4b3..9faeae7d67 100644 --- a/models/organization/org_test.go +++ b/models/organization/org_test.go @@ -378,18 +378,18 @@ func TestHasOrgVisibleTypePublic(t *testing.T) { assert.NoError(t, organization.CreateOrganization(t.Context(), org, owner)) org = unittest.AssertExistsAndLoadBean(t, &organization.Organization{Name: org.Name, Type: user_model.UserTypeOrganization}) - test1 := organization.HasOrgOrUserVisible(t.Context(), org.AsUser(), owner) - test2 := organization.HasOrgOrUserVisible(t.Context(), org.AsUser(), org3) - test3 := organization.HasOrgOrUserVisible(t.Context(), org.AsUser(), nil) + test1 := organization.IsOwnerVisibleToDoer(t.Context(), org.AsUser(), owner) + test2 := organization.IsOwnerVisibleToDoer(t.Context(), org.AsUser(), org3) + test3 := organization.IsOwnerVisibleToDoer(t.Context(), org.AsUser(), nil) assert.True(t, test1) // owner of org assert.True(t, test2) // user not a part of org assert.True(t, test3) // logged out user restrictedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 29, IsRestricted: true}) require.True(t, restrictedUser.IsRestricted) - assert.True(t, organization.HasOrgOrUserVisible(t.Context(), org.AsUser(), restrictedUser)) + assert.True(t, organization.IsOwnerVisibleToDoer(t.Context(), org.AsUser(), restrictedUser)) defer test.MockVariableValue(&setting.Service.RequireSignInViewStrict, true)() - assert.False(t, organization.HasOrgOrUserVisible(t.Context(), org.AsUser(), restrictedUser)) + assert.False(t, organization.IsOwnerVisibleToDoer(t.Context(), org.AsUser(), restrictedUser)) } func TestHasOrgVisibleTypeLimited(t *testing.T) { @@ -407,9 +407,9 @@ func TestHasOrgVisibleTypeLimited(t *testing.T) { assert.NoError(t, organization.CreateOrganization(t.Context(), org, owner)) org = unittest.AssertExistsAndLoadBean(t, &organization.Organization{Name: org.Name, Type: user_model.UserTypeOrganization}) - test1 := organization.HasOrgOrUserVisible(t.Context(), org.AsUser(), owner) - test2 := organization.HasOrgOrUserVisible(t.Context(), org.AsUser(), org3) - test3 := organization.HasOrgOrUserVisible(t.Context(), org.AsUser(), nil) + test1 := organization.IsOwnerVisibleToDoer(t.Context(), org.AsUser(), owner) + test2 := organization.IsOwnerVisibleToDoer(t.Context(), org.AsUser(), org3) + test3 := organization.IsOwnerVisibleToDoer(t.Context(), org.AsUser(), nil) assert.True(t, test1) // owner of org assert.True(t, test2) // user not a part of org assert.False(t, test3) // logged out user @@ -430,9 +430,9 @@ func TestHasOrgVisibleTypePrivate(t *testing.T) { assert.NoError(t, organization.CreateOrganization(t.Context(), org, owner)) org = unittest.AssertExistsAndLoadBean(t, &organization.Organization{Name: org.Name, Type: user_model.UserTypeOrganization}) - test1 := organization.HasOrgOrUserVisible(t.Context(), org.AsUser(), owner) - test2 := organization.HasOrgOrUserVisible(t.Context(), org.AsUser(), org3) - test3 := organization.HasOrgOrUserVisible(t.Context(), org.AsUser(), nil) + test1 := organization.IsOwnerVisibleToDoer(t.Context(), org.AsUser(), owner) + test2 := organization.IsOwnerVisibleToDoer(t.Context(), org.AsUser(), org3) + test3 := organization.IsOwnerVisibleToDoer(t.Context(), org.AsUser(), nil) assert.True(t, test1) // owner of org assert.False(t, test2) // user not a part of org assert.False(t, test3) // logged out user diff --git a/models/perm/access/repo_permission.go b/models/perm/access/repo_permission.go index 31bf064ba7..c6e65f3f72 100644 --- a/models/perm/access/repo_permission.go +++ b/models/perm/access/repo_permission.go @@ -427,8 +427,7 @@ func GetIndividualUserRepoPermission(ctx context.Context, repo *repo_model.Repos // Prevent strangers from checking out public repo of private organization/users // Allow user if they are a collaborator of a repo within a private user or a private organization but not a member of the organization itself - // TODO: rename it to "IsOwnerVisibleToDoer" - if !organization.HasOrgOrUserVisible(ctx, repo.Owner, user) && !isCollaborator { + if !organization.IsOwnerVisibleToDoer(ctx, repo.Owner, user) && !isCollaborator { perm.AccessMode = perm_model.AccessModeNone return perm, nil } diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index aa615059fe..e8adfd2668 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -2837,6 +2837,21 @@ "org.settings.enable_licensing_help": "Show the Licenses page in the org menu and enable license key management. Individual repos can also enable licensing independently.", "org.settings.require_key": "Require license key for all update feeds", "org.settings.require_key_help": "Update feeds return empty results unless a valid key is provided. Joomla clients will see a Download Key field. Individual repos can override this.", + "org.settings.extension_metadata": "Extension Metadata", + "org.settings.extension_metadata_desc": "Configure how this extension appears in update feeds. These fields are used when generating updates.xml, JSON feeds, and package metadata.", + "org.settings.update_platform": "Update Feed Format", + "org.settings.extension_name": "Element Name", + "org.settings.extension_name_help": "The unique extension identifier as registered in the CMS (e.g. pkg_mokowaas, com_akeebabackup).", + "org.settings.display_name": "Display Name", + "org.settings.display_name_help": "Human-readable name shown in the CMS update manager.", + "org.settings.extension_type": "Extension Type", + "org.settings.target_version": "Target Platform Version", + "org.settings.target_version_help": "Regex pattern for compatible CMS versions (e.g. (5|6)\\..*). Leave empty for all versions.", + "org.settings.maintainer": "Maintainer", + "org.settings.maintainer_url": "Maintainer URL", + "org.settings.info_url": "Info/Product URL", + "org.settings.info_url_help": "Link to the extension's product or documentation page.", + "org.settings.php_minimum": "Minimum PHP Version", "org.settings.update_streams_heading": "Update Streams", "org.settings.update_streams_desc": "Configure the default update streams for all repositories. Release tags are matched to streams by their suffix. Repos can override with per-repo settings.", "org.settings.stream_mode": "Stream Mode", diff --git a/routers/api/v1/org/org.go b/routers/api/v1/org/org.go index 19dcfc0afb..c78b7ba527 100644 --- a/routers/api/v1/org/org.go +++ b/routers/api/v1/org/org.go @@ -145,8 +145,8 @@ func GetUserOrgsPermissions(ctx *context.APIContext) { op := api.OrganizationPermissions{} - if !organization.HasOrgOrUserVisible(ctx, o, ctx.Doer) { - ctx.APIErrorNotFound("HasOrgOrUserVisible", nil) + if !organization.IsOwnerVisibleToDoer(ctx, o, ctx.Doer) { + ctx.APIErrorNotFound("IsOwnerVisibleToDoer", nil) return } @@ -311,8 +311,8 @@ func Get(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" - if !organization.HasOrgOrUserVisible(ctx, ctx.Org.Organization.AsUser(), ctx.Doer) { - ctx.APIErrorNotFound("HasOrgOrUserVisible", nil) + if !organization.IsOwnerVisibleToDoer(ctx, ctx.Org.Organization.AsUser(), ctx.Doer) { + ctx.APIErrorNotFound("IsOwnerVisibleToDoer", nil) return } diff --git a/routers/api/v1/repo/fork.go b/routers/api/v1/repo/fork.go index 4413493564..8adb6f9122 100644 --- a/routers/api/v1/repo/fork.go +++ b/routers/api/v1/repo/fork.go @@ -93,7 +93,7 @@ func prepareDoerCreateRepoInOrg(ctx *context.APIContext, orgName string) *organi return nil } - if !organization.HasOrgOrUserVisible(ctx, org.AsUser(), ctx.Doer) { + if !organization.IsOwnerVisibleToDoer(ctx, org.AsUser(), ctx.Doer) { ctx.APIErrorNotFound() return nil } diff --git a/routers/web/org/update_streams.go b/routers/web/org/update_streams.go index 9f5b5ace06..6f019ab62f 100644 --- a/routers/web/org/update_streams.go +++ b/routers/web/org/update_streams.go @@ -40,9 +40,19 @@ func SettingsUpdateStreamsPost(ctx *context.Context) { OwnerID: orgID, RepoID: 0, StreamMode: ctx.FormString("stream_mode"), + Platform: ctx.FormString("platform"), CustomStreams: ctx.FormString("custom_streams"), LicensingEnabled: ctx.FormString("licensing_enabled") == "on", RequireKey: ctx.FormString("require_key") == "on", + ExtensionName: ctx.FormString("extension_name"), + DisplayName: ctx.FormString("display_name"), + Description: ctx.FormString("feed_description"), + ExtensionType: ctx.FormString("extension_type"), + Maintainer: ctx.FormString("maintainer"), + MaintainerURL: ctx.FormString("maintainer_url"), + InfoURL: ctx.FormString("info_url"), + TargetVersion: ctx.FormString("target_version"), + PHPMinimum: ctx.FormString("php_minimum"), } if cfg.StreamMode == "" { diff --git a/services/context/package.go b/services/context/package.go index ce2007d0ad..a566ad5d09 100644 --- a/services/context/package.go +++ b/services/context/package.go @@ -149,7 +149,7 @@ func determineAccessMode(ctx *Base, pkgOwner, doer *user_model.User) (perm.Acces } } } - if accessMode == perm.AccessModeNone && organization.HasOrgOrUserVisible(ctx, pkgOwner, doer) { + if accessMode == perm.AccessModeNone && organization.IsOwnerVisibleToDoer(ctx, pkgOwner, doer) { // 2. If user is unauthorized or no org member, check if org is visible accessMode = perm.AccessModeRead } diff --git a/services/updateserver/joomla.go b/services/updateserver/joomla.go index 2d27bb93ae..2fe229b67c 100644 --- a/services/updateserver/joomla.go +++ b/services/updateserver/joomla.go @@ -125,8 +125,8 @@ func NormalizeChannel(ch string) string { } // GenerateJoomlaXML builds a Joomla-compatible updates.xml from repository releases. -// It returns the raw XML bytes. The element, maintainer, and target platform -// are derived from the repo name and owner. +// It returns the raw XML bytes. Extension metadata is read from the update stream config; +// falls back to repo name/owner when not configured. // allowedChannels optionally restricts output to specific channels (nil = all). func GenerateJoomlaXML(ctx context.Context, repo *repo_model.Repository, requireKey bool, allowedChannels ...string) ([]byte, error) { releases, err := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{ @@ -149,7 +149,41 @@ func GenerateJoomlaXML(ctx context.Context, repo *repo_model.Repository, require } repoLink := fmt.Sprintf("%s/%s/%s", baseURL, repo.Owner.Name, repo.Name) + // Load extension metadata from config (falls back to repo-derived values). + cfg := licenses.GetEffectiveConfig(ctx, repo.OwnerID, repo.ID) + element := strings.ToLower(repo.Name) + if cfg != nil && cfg.ExtensionName != "" { + element = cfg.ExtensionName + } + displayName := fmt.Sprintf("%s - %s", repo.Owner.Name, repo.Name) + if cfg != nil && cfg.DisplayName != "" { + displayName = cfg.DisplayName + } + extType := "component" + if cfg != nil && cfg.ExtensionType != "" { + extType = cfg.ExtensionType + } + maintainer := repo.Owner.Name + if cfg != nil && cfg.Maintainer != "" { + maintainer = cfg.Maintainer + } + maintainerURL := fmt.Sprintf("%s/%s", baseURL, repo.Owner.Name) + if cfg != nil && cfg.MaintainerURL != "" { + maintainerURL = cfg.MaintainerURL + } + targetVersion := ".*" + if cfg != nil && cfg.TargetVersion != "" { + targetVersion = cfg.TargetVersion + } + phpMinimum := "" + if cfg != nil && cfg.PHPMinimum != "" { + phpMinimum = cfg.PHPMinimum + } + feedDescription := "" + if cfg != nil && cfg.Description != "" { + feedDescription = cfg.Description + } // Resolve effective streams (repo override → org default → Joomla default). streams := licenses.GetEffectiveStreams(ctx, repo.OwnerID, repo.ID) @@ -215,30 +249,41 @@ func GenerateJoomlaXML(ctx context.Context, repo *repo_model.Repository, require version = version + suffix } + desc := feedDescription + if desc == "" { + desc = fmt.Sprintf("%s %s build.", displayName, ch) + } + + infoURL := fmt.Sprintf("%s/releases/tag/%s", repoLink, rel.TagName) + if cfg != nil && cfg.InfoURL != "" { + infoURL = cfg.InfoURL + } + u := xmlUpdate{ - Name: fmt.Sprintf("%s - %s", repo.Owner.Name, repo.Name), - Description: fmt.Sprintf("%s - %s %s build.", repo.Owner.Name, repo.Name, ch), + Name: displayName, + Description: desc, Element: element, - Type: "component", + Type: extType, Client: "site", Version: version, CreationDate: time.Unix(int64(rel.CreatedUnix), 0).Format("2006-01-02"), InfoURL: xmlInfoURL{ - Title: fmt.Sprintf("%s - %s", repo.Owner.Name, repo.Name), - URL: fmt.Sprintf("%s/releases/tag/%s", repoLink, rel.TagName), + Title: displayName, + URL: infoURL, }, Downloads: xmlDownloads{ DownloadURL: []xmlDownloadURL{ {Type: "full", Format: "zip", URL: downloadURL}, }, }, - Tags: xmlTags{Tag: ch}, + Tags: xmlTags{Tag: ch}, ChangelogURL: fmt.Sprintf("%s/raw/branch/%s/CHANGELOG.md", repoLink, repo.DefaultBranch), - Maintainer: repo.Owner.Name, - MaintainerURL: fmt.Sprintf("%s/%s", baseURL, repo.Owner.Name), + Maintainer: maintainer, + MaintainerURL: maintainerURL, + PHPMinimum: phpMinimum, TargetPlatform: xmlTargetPlat{ Name: "joomla", - Version: ".*", + Version: targetVersion, }, } diff --git a/templates/org/settings/update_streams.tmpl b/templates/org/settings/update_streams.tmpl index 16bcad18c3..2706855faf 100644 --- a/templates/org/settings/update_streams.tmpl +++ b/templates/org/settings/update_streams.tmpl @@ -29,7 +29,86 @@
- {{/* ── Section 2: Update Streams ── */}} + {{/* ── Section 2: Extension Metadata ── */}} +{{ctx.Locale.Tr "org.settings.extension_metadata_desc"}}
+ +{{ctx.Locale.Tr "org.settings.extension_name_help"}}
+{{ctx.Locale.Tr "org.settings.display_name_help"}}
+{{ctx.Locale.Tr "org.settings.target_version_help"}}
+{{ctx.Locale.Tr "org.settings.info_url_help"}}
+{{ctx.Locale.Tr "org.settings.update_streams_desc"}}
diff --git a/templates/repo/header.tmpl b/templates/repo/header.tmpl index 7a58c53a90..1cb6560e77 100644 --- a/templates/repo/header.tmpl +++ b/templates/repo/header.tmpl @@ -128,7 +128,7 @@ {{end}} - {{if or .EnableLicenses .IsRepoAdmin}} + {{if .LicensingEnabled}} {{svg "octicon-key"}} {{ctx.Locale.Tr "repo.licenses"}} {{if .NumLicensePackages}} diff --git a/templates/repo/issue/fields/dropdown.tmpl b/templates/repo/issue/fields/dropdown.tmpl index 26505f58a5..2eb76fe543 100644 --- a/templates/repo/issue/fields/dropdown.tmpl +++ b/templates/repo/issue/fields/dropdown.tmpl @@ -1,8 +1,7 @@