From 9a8d9bc504a022cb1d1867a0a3622d122b1520b7 Mon Sep 17 00:00:00 2001 From: Moko Consulting Date: Sat, 18 Jul 2026 21:07:28 -0500 Subject: [PATCH] feat(updateserver): npm/mcp return N-A instead of Joomla feed; derive platform from metadata [#810 #811] #810: The update-feed handlers no longer fall back to a Joomla feed for unset or feedless platforms. Every Serve* handler now gates positively on its own platform constant, so npm/mcp (and any unrecognized platform) return NotFound instead of an (empty) Joomla updates.xml. Added npm/mcp as recognized platform constants and an IsFeedlessPlatform helper. #811: The effective update-stream platform (RepoUpdatePlatform) is now resolved via ResolvePlatform(cfgPlatform, metadataPlatform): an explicit update-stream config platform wins; otherwise it derives from the repo metadata platform (metadata.platform); only when neither is set does it fall back to "joomla". This stops the update-stream store and the repo metadata store from drifting and removes the "empty => joomla" fallback at its root (services/context/repo_public_feed.go and repo.go). No schema/migration required: UpdateStreamConfig.Platform is a plain string column with no DB-level enum/CHECK constraint, so npm/mcp are enforced at the application layer only. Authored-by: Moko Consulting --- models/updateserver/platform.go | 67 ++++++++++++++++++++++++++++ routers/web/repo/updateserver.go | 47 ++++++++++++------- services/context/repo.go | 17 +++++-- services/context/repo_public_feed.go | 16 +++++-- 4 files changed, 122 insertions(+), 25 deletions(-) create mode 100644 models/updateserver/platform.go diff --git a/models/updateserver/platform.go b/models/updateserver/platform.go new file mode 100644 index 0000000000..0d68c8d763 --- /dev/null +++ b/models/updateserver/platform.go @@ -0,0 +1,67 @@ +// Copyright 2026 Moko Consulting +// 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 +} diff --git a/routers/web/repo/updateserver.go b/routers/web/repo/updateserver.go index a2b5737e5d..b30ccb9253 100644 --- a/routers/web/repo/updateserver.go +++ b/routers/web/repo/updateserver.go @@ -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 } diff --git a/services/context/repo.go b/services/context/repo.go index 7ce31c7ae2..8a9ccf770b 100644 --- a/services/context/repo.go +++ b/services/context/repo.go @@ -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 diff --git a/services/context/repo_public_feed.go b/services/context/repo_public_feed.go index 25ee216c33..d07aea33e8 100644 --- a/services/context/repo_public_feed.go +++ b/services/context/repo_public_feed.go @@ -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) } -- 2.52.0