feat(updateserver): npm/mcp return N-A instead of Joomla feed; derive platform from metadata [#810 #811] #825

Merged
jmiller merged 1 commits from feature/updateserver-npm-mcp into dev 2026-07-19 02:49:06 +00:00
4 changed files with 122 additions and 25 deletions
+67
View File
@@ -0,0 +1,67 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package updateserver
import "strings"
// Update-feed platform identifiers. These are the recognized values for the
// UpdateStreamConfig.Platform column and for the resolved "RepoUpdatePlatform"
// context value used by the Serve* update-feed handlers.
//
// The Platform column is a plain string (no DB-level enum/CHECK constraint), so
// these constants are enforced at the application layer only — adding new values
// requires no schema migration.
const (
PlatformJoomla = "joomla"
PlatformDolibarr = "dolibarr"
PlatformBoth = "both" // joomla + dolibarr
PlatformWordPress = "wordpress"
PlatformPrestaShop = "prestashop"
PlatformDrupal = "drupal"
PlatformComposer = "composer"
PlatformWHMCS = "whmcs"
// Feedless platforms: repositories whose distribution channel is an external
// package registry (npm, MCP). They must NOT emit any Joomla/Dolibarr/etc.
// update feed — the Serve* handlers return NotFound for these. See #810.
PlatformNPM = "npm"
PlatformMCP = "mcp"
)
// feedlessPlatforms are platforms that never emit a legacy update feed.
var feedlessPlatforms = map[string]struct{}{
PlatformNPM: {},
PlatformMCP: {},
}
// IsFeedlessPlatform reports whether the given platform must not serve any
// update feed (npm / mcp). These repositories are published to an external
// registry, so every Serve* handler returns NotFound for them. See #810.
func IsFeedlessPlatform(platform string) bool {
_, ok := feedlessPlatforms[strings.ToLower(strings.TrimSpace(platform))]
return ok
}
// ResolvePlatform determines the effective update-feed platform for a repo.
//
// - If the update-stream config sets an explicit platform, that wins.
// - Otherwise the platform is derived from the repository metadata platform
// (metadata.platform), so the two stores can no longer drift. See #811.
// - Feedless metadata values (npm / mcp) are propagated verbatim so the
// handlers can return NotFound instead of falling back to a Joomla feed.
// - Only when neither source is set do we fall back to the historical
// "joomla" default (used by legacy Joomla extension repos).
//
// This is the single source of truth for the old "empty ⇒ joomla" fallback,
// which previously caused npm/mcp repos with no config row to emit a Joomla
// feed. See #810 and #811.
func ResolvePlatform(cfgPlatform, metadataPlatform string) string {
if p := strings.ToLower(strings.TrimSpace(cfgPlatform)); p != "" {
return p
}
if p := strings.ToLower(strings.TrimSpace(metadataPlatform)); p != "" {
return p
}
return PlatformJoomla
}
+30 -17
View File
@@ -78,10 +78,13 @@ func validateUpdateKey(ctx *context.Context) (allowedChannels []string, ok bool,
// ServeUpdatesXML generates and serves a Joomla-compatible updates.xml
// from the repository's releases.
func ServeUpdatesXML(ctx *context.Context) {
// Block if platform is set to a non-Joomla value.
// Empty/unset defaults to joomla for backwards compatibility.
// Only serve the Joomla feed for joomla/both platforms. The platform is
// resolved upstream (config override → repo metadata → "joomla" default),
// so an unset or feedless (npm/mcp) platform never reaches the Joomla
// generator. Any other platform — including npm/mcp — returns NotFound.
// See #810 / #811.
platform, _ := ctx.Data["RepoUpdatePlatform"].(string)
if platform != "" && platform != "joomla" && platform != "both" {
if platform != updateserver_model.PlatformJoomla && platform != updateserver_model.PlatformBoth {
ctx.NotFound(nil)
return
}
@@ -114,9 +117,10 @@ func ServeUpdatesXML(ctx *context.Context) {
// from the repository's releases. Uses the same license key validation as the
// Joomla XML feed — all platforms share the same licensing system.
func ServeDolibarrJSON(ctx *context.Context) {
// Block if platform doesn't include dolibarr.
platform := ctx.Data["RepoUpdatePlatform"]
if platform == "joomla" || platform == "" {
// Only serve for dolibarr/both. Feedless platforms (npm/mcp) and any other
// platform return NotFound rather than a feed. See #810 / #811.
platform, _ := ctx.Data["RepoUpdatePlatform"].(string)
if platform != updateserver_model.PlatformDolibarr && platform != updateserver_model.PlatformBoth {
ctx.NotFound(nil)
return
}
@@ -149,9 +153,10 @@ func ServeDolibarrJSON(ctx *context.Context) {
// ServeWordPressJSON generates and serves a WordPress PUC-compatible update feed.
// Compatible with the YahnisElsts plugin-update-checker library.
func ServeWordPressJSON(ctx *context.Context) {
// Block if platform doesn't include wordpress.
platform := ctx.Data["RepoUpdatePlatform"]
if platform != "wordpress" && platform != "both" {
// Only serve for wordpress/both. Feedless platforms (npm/mcp) and any other
// platform return NotFound. See #810.
platform, _ := ctx.Data["RepoUpdatePlatform"].(string)
if platform != updateserver_model.PlatformWordPress && platform != updateserver_model.PlatformBoth {
ctx.NotFound(nil)
return
}
@@ -195,8 +200,10 @@ func ServeWordPressJSON(ctx *context.Context) {
// ServeComposerJSON generates and serves a Composer packages.json feed.
func ServeComposerJSON(ctx *context.Context) {
platform := ctx.Data["RepoUpdatePlatform"]
if platform != "composer" {
// Only serve for composer. Feedless platforms (npm/mcp) and any other
// platform return NotFound. See #810.
platform, _ := ctx.Data["RepoUpdatePlatform"].(string)
if platform != updateserver_model.PlatformComposer {
ctx.NotFound(nil)
return
}
@@ -233,8 +240,10 @@ func ServeComposerJSON(ctx *context.Context) {
// ServePrestaShopXML generates and serves a PrestaShop module update XML.
func ServePrestaShopXML(ctx *context.Context) {
platform := ctx.Data["RepoUpdatePlatform"]
if platform != "prestashop" {
// Only serve for prestashop. Feedless platforms (npm/mcp) and any other
// platform return NotFound. See #810.
platform, _ := ctx.Data["RepoUpdatePlatform"].(string)
if platform != updateserver_model.PlatformPrestaShop {
ctx.NotFound(nil)
return
}
@@ -260,8 +269,10 @@ func ServePrestaShopXML(ctx *context.Context) {
// ServeDrupalXML generates and serves a Drupal update status XML.
func ServeDrupalXML(ctx *context.Context) {
platform := ctx.Data["RepoUpdatePlatform"]
if platform != "drupal" {
// Only serve for drupal. Feedless platforms (npm/mcp) and any other
// platform return NotFound. See #810.
platform, _ := ctx.Data["RepoUpdatePlatform"].(string)
if platform != updateserver_model.PlatformDrupal {
ctx.NotFound(nil)
return
}
@@ -287,8 +298,10 @@ func ServeDrupalXML(ctx *context.Context) {
// ServeWHMCSJSON generates and serves a WHMCS module update JSON.
func ServeWHMCSJSON(ctx *context.Context) {
platform := ctx.Data["RepoUpdatePlatform"]
if platform != "whmcs" {
// Only serve for whmcs. Feedless platforms (npm/mcp) and any other
// platform return NotFound. See #810.
platform, _ := ctx.Data["RepoUpdatePlatform"].(string)
if platform != updateserver_model.PlatformWHMCS {
ctx.NotFound(nil)
return
}
+13 -4
View File
@@ -701,12 +701,21 @@ func repoAssignmentPrepareTemplateData(ctx *Context, data *repoAssignmentPrepare
ctx.Data["IsRepoAdmin"] = ctx.Repo.Permission.IsAdmin()
ctx.Data["IsSiteAdmin"] = ctx.IsUserSiteAdmin()
// Load repo update config for platform-aware UI.
// Resolve the effective update platform for platform-aware UI. When the
// update-stream config has no explicit platform, derive it from the repo
// metadata (metadata.platform) so npm/mcp repos are recognized and the two
// stores can't drift. See #810 / #811.
cfgPlatform := ""
if repoUpdateCfg != nil {
ctx.Data["RepoUpdatePlatform"] = repoUpdateCfg.Platform
} else {
ctx.Data["RepoUpdatePlatform"] = "joomla"
cfgPlatform = repoUpdateCfg.Platform
}
metadataPlatform := ""
if meta, err := repo_model.GetRepoMetadata(ctx, repo.ID); err != nil {
log.Error("GetRepoMetadata: %v", err)
} else if meta != nil {
metadataPlatform = meta.Platform
}
ctx.Data["RepoUpdatePlatform"] = updateserver_model.ResolvePlatform(cfgPlatform, metadataPlatform)
ctx.Data["Title"] = repo.Owner.Name + "/" + repo.Name
ctx.Data["PageTitleCommon"] = repo.Name + " - " + setting.AppName
+12 -4
View File
@@ -4,8 +4,8 @@
package context
import (
updateserver_model "code.mokoconsulting.tech/MokoConsulting/MokoGIT/models/updateserver"
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGIT/models/repo"
updateserver_model "code.mokoconsulting.tech/MokoConsulting/MokoGIT/models/updateserver"
user_model "code.mokoconsulting.tech/MokoConsulting/MokoGIT/models/user"
"code.mokoconsulting.tech/MokoConsulting/MokoGIT/modules/log"
)
@@ -50,10 +50,18 @@ func RepoAssignmentPublicFeed() func(ctx *Context) {
return
}
ctx.Data["RepoUpdatePlatform"] = cfg.Platform
if cfg.Platform == "" {
ctx.Data["RepoUpdatePlatform"] = "joomla"
// Resolve the effective feed platform. When the update-stream config has
// no explicit platform override, derive it from the repo metadata
// (metadata.platform) so the two stores can't drift, and so npm/mcp repos
// are recognized as feedless instead of falling back to a Joomla feed.
// See #810 / #811.
metadataPlatform := ""
if meta, err := repo_model.GetRepoMetadata(ctx, repo.ID); err != nil {
log.Error("GetRepoMetadata: %v", err)
} else if meta != nil {
metadataPlatform = meta.Platform
}
ctx.Data["RepoUpdatePlatform"] = updateserver_model.ResolvePlatform(cfg.Platform, metadataPlatform)
log.Trace("Public feed access: %s/%s", ownerName, repoName)
}