diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index de44e2783b..8fdcd1e030 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -2999,3 +2999,23 @@ LEVEL = Info ;SERVICE_TYPE = memory ;; Ignored for the "memory" type. For "redis" use something like `redis://127.0.0.1:6379/0` ;SERVICE_CONN_STR = + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Repository metadata / platform registry +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;[metadata] +;; Comma-separated list of allowed values for the repo metadata "platform" field. +;; Also editable via Admin -> Metadata (persisted here). Default taxonomy: +;PLATFORM_OPTIONS = joomla,dolibarr,go,npm,generic +;; +;; Structured platform registry: capability attributes per platform key, read by +;; CI, the update server, and workflow sync (exposed at GET /api/v1/platforms). +;; Value is EITHER an inline JSON array of platform objects OR a path to a JSON +;; file (relative paths resolve against the custom/ dir). When unset, a built-in +;; default registry is used (joomla, dolibarr, go, npm, mcp, generic), so +;; behavior is unchanged if this is not configured. +;; Example (file path): +;PLATFORM_REGISTRY = custom/platforms.json +;; Example (inline JSON): +;;PLATFORM_REGISTRY = [{"key":"joomla","label":"Joomla Extension","family":"joomla","artifact":true,"update_generator":"joomla","feed_format":"xml","template_repo":"Template-Joomla","manifest_based":true}] diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 13c5c60701..f508e23ce9 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -449,6 +449,7 @@ func prepareMigrationTasks() []*migration { newMigration(369, "Rename mokogitea-actions system user to mokogit-actions", v1_27.RenameActionsUserToMokoGit), newMigration(370, "Remap saved gitea-* user themes to mokogit-*", v1_27.RemapGiteaThemesToMokogit), newMigration(371, "Widen action_run_job.workflow_payload to MEDIUMBLOB", v1_27.WidenActionRunJobWorkflowPayload), + newMigration(372, "Add npm/mcp fields to repo manifest", v1_27.AddNpmFieldsToRepoManifest), } return preparedMigrations } diff --git a/models/migrations/v1_27/v373.go b/models/migrations/v1_27/v373.go new file mode 100644 index 0000000000..c1c8a18cbd --- /dev/null +++ b/models/migrations/v1_27/v373.go @@ -0,0 +1,23 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package v1_27 + +import ( + "xorm.io/xorm" +) + +// AddNpmFieldsToRepoManifest adds npm/mcp metadata columns to repo_manifest so +// the PUT /api/v1/repos/{owner}/{repo}/metadata handler can persist them +// instead of silently dropping them. See issue #363. +func AddNpmFieldsToRepoManifest(x *xorm.Engine) error { + type RepoManifest struct { + NodeMinimum string `xorm:"VARCHAR(20) 'node_minimum'"` + NpmPackage string `xorm:"TEXT 'npm_package'"` + NpmScope string `xorm:"VARCHAR(100) 'npm_scope'"` + RegistryURL string `xorm:"TEXT 'registry_url'"` + Bin string `xorm:"TEXT 'bin'"` + PublishTarget string `xorm:"VARCHAR(50) 'publish_target'"` + } + return x.Sync(new(RepoManifest)) +} diff --git a/models/repo/repo_manifest.go b/models/repo/repo_manifest.go index 9907fc16a1..2d95d4fd9e 100644 --- a/models/repo/repo_manifest.go +++ b/models/repo/repo_manifest.go @@ -52,6 +52,14 @@ type RepoMetadata struct { ExtensionType string `xorm:"VARCHAR(50) 'extension_type'"` // component, module, plugin, package, template, library, file EntryPoint string `xorm:"TEXT 'entry_point'"` // build entry point path + // npm/mcp section (used by node/npm publish workflows and mokocli) + NodeMinimum string `xorm:"VARCHAR(20) 'node_minimum'"` // minimum Node.js version, e.g. "18" + NpmPackage string `xorm:"TEXT 'npm_package'"` // published npm package name, e.g. "@moko/foo" + NpmScope string `xorm:"VARCHAR(100) 'npm_scope'"` // npm scope, e.g. "@moko" + RegistryURL string `xorm:"TEXT 'registry_url'"` // npm registry URL + Bin string `xorm:"TEXT 'bin'"` // bin entry (name or JSON map) + PublishTarget string `xorm:"VARCHAR(50) 'publish_target'"` // publish target, e.g. "npm", "registry" + // deploy section DeployHost string `xorm:"VARCHAR(255) 'deploy_host'"` // SSH host for deploy DeployPort string `xorm:"VARCHAR(10) 'deploy_port'"` // SSH port (default 2918) 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/modules/setting/metadata.go b/modules/setting/metadata.go index 777f681e35..5b1025151a 100644 --- a/modules/setting/metadata.go +++ b/modules/setting/metadata.go @@ -19,4 +19,8 @@ func loadMetadataFrom(rootCfg ConfigProvider) { if opts := sec.Key("PLATFORM_OPTIONS").Strings(","); len(opts) > 0 { Metadata.PlatformOptions = opts } + + // Load the structured platform registry ([metadata] PLATFORM_REGISTRY). + // Falls back to a built-in default reconciled with PLATFORM_OPTIONS. + loadPlatformRegistryFrom(rootCfg) } diff --git a/modules/setting/platform_registry.go b/modules/setting/platform_registry.go new file mode 100644 index 0000000000..c248373d1e --- /dev/null +++ b/modules/setting/platform_registry.go @@ -0,0 +1,255 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package setting + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" +) + +// PlatformDef is a single platform's capability record in the platform registry. +// +// A platform key (matching repo_manifest.platform and UpdateStreamConfig.Platform) +// carries the capability attributes that CI, the update server, and the workflow +// sync tooling read instead of hardcoding per-platform behavior. Only Key is +// required; every other field has a sensible zero/default so a bare key still +// yields a usable (generic) record. +// +// This registry is NON-SECRET capability config only. It never carries +// credentials, tokens, or deploy keys. +type PlatformDef struct { + // Key is the stable lowercase identifier (e.g. "joomla"). Matches + // repo_manifest.platform and UpdateStreamConfig.Platform. + Key string `json:"key"` + // Label is the human-readable display name. + Label string `json:"label,omitempty"` + // Aliases are alternate keys matched (by prefix) in the release gate, + // e.g. joomla5, joomla6. + Aliases []string `json:"aliases,omitempty"` + // Family groups related platforms: joomla | dolibarr | node | go | generic | dot. + Family string `json:"family,omitempty"` + // Artifact reports whether the platform produces a build artifact. + Artifact bool `json:"artifact"` + // UpdateGenerator names the update-feed generator the update server uses: + // joomla | dolibarr | wordpress | composer | prestashop | drupal | whmcs | npm | none. + UpdateGenerator string `json:"update_generator,omitempty"` + // FeedFormat is the update-feed wire format: xml | json | none. + FeedFormat string `json:"feed_format,omitempty"` + // TemplateRepo is the workflow-template source repo (e.g. Template-Joomla). + TemplateRepo string `json:"template_repo,omitempty"` + // ManifestBased reports whether a manifest is parsed for element/version. + ManifestBased bool `json:"manifest_based"` + // PublishTarget names where artifacts are published (e.g. npm, updateserver, git-release). + PublishTarget string `json:"publish_target,omitempty"` + // DetectGlobs are file-pattern globs used to auto-detect this platform. + DetectGlobs []string `json:"detect_globs,omitempty"` +} + +// Platforms holds the loaded platform registry indexed by key (lowercase). +// It is populated by loadMetadataFrom at startup. Callers should use +// PlatformRegistry(), GetPlatform() and PlatformList() rather than reading the +// map directly. +var platformRegistry map[string]*PlatformDef + +// PlatformRegistry returns the loaded registry as a key->def map. Never nil. +func PlatformRegistry() map[string]*PlatformDef { + if platformRegistry == nil { + platformRegistry = buildDefaultRegistry(Metadata.PlatformOptions) + } + return platformRegistry +} + +// PlatformList returns registry entries in the order of Metadata.PlatformOptions +// (falling back to a stable built-in order for any extra keys). +func PlatformList() []*PlatformDef { + reg := PlatformRegistry() + out := make([]*PlatformDef, 0, len(reg)) + seen := make(map[string]bool, len(reg)) + // Preserve admin-configured order first. + for _, k := range Metadata.PlatformOptions { + k = strings.ToLower(strings.TrimSpace(k)) + if def, ok := reg[k]; ok && !seen[k] { + out = append(out, def) + seen[k] = true + } + } + // Append any registry-only keys not present in PlatformOptions. + for k, def := range reg { + if !seen[k] { + out = append(out, def) + seen[k] = true + } + } + return out +} + +// GetPlatform returns the definition for a platform key. If the key is unknown, +// it synthesizes a generic default so callers never get a nil (matches the +// back-compat guarantee that an unlisted platform behaves like "generic"). +// The bool reports whether the key was explicitly present in the registry. +func GetPlatform(key string) (*PlatformDef, bool) { + key = strings.ToLower(strings.TrimSpace(key)) + reg := PlatformRegistry() + if def, ok := reg[key]; ok { + return def, true + } + return genericPlatformDef(key), false +} + +// genericPlatformDef returns a safe generic capability record for an unknown key. +func genericPlatformDef(key string) *PlatformDef { + return &PlatformDef{ + Key: key, + Label: key, + Family: "generic", + Artifact: false, + UpdateGenerator: "none", + FeedFormat: "none", + TemplateRepo: "Template-Generic", + ManifestBased: false, + PublishTarget: "git-release", + } +} + +// builtinRegistry is the default platform registry. It reproduces current +// behavior so an unconfigured instance is unchanged: Joomla still maps to the +// joomla generator (xml), Dolibarr to dolibarr (json), npm/mcp are feedless, etc. +func builtinRegistry() []*PlatformDef { + return []*PlatformDef{ + { + Key: "joomla", Label: "Joomla Extension", Aliases: []string{"joomla5", "joomla6"}, + Family: "joomla", Artifact: true, UpdateGenerator: "joomla", FeedFormat: "xml", + TemplateRepo: "Template-Joomla", ManifestBased: true, PublishTarget: "updateserver", + DetectGlobs: []string{"source/pkg_*.xml", "**/templateDetails.xml"}, + }, + { + Key: "dolibarr", Label: "Dolibarr Module", + Family: "dolibarr", Artifact: true, UpdateGenerator: "dolibarr", FeedFormat: "json", + TemplateRepo: "Template-Dolibarr", ManifestBased: true, PublishTarget: "updateserver", + DetectGlobs: []string{"htdocs/**/core/modules/mod*.class.php"}, + }, + { + Key: "go", Label: "Go Module", + Family: "go", Artifact: false, UpdateGenerator: "none", FeedFormat: "none", + TemplateRepo: "Template-Go", ManifestBased: false, PublishTarget: "git-release", + DetectGlobs: []string{"go.mod"}, + }, + { + Key: "npm", Label: "NPM Package", + Family: "node", Artifact: true, UpdateGenerator: "npm", FeedFormat: "none", + TemplateRepo: "Template-NPM", ManifestBased: true, PublishTarget: "npm", + DetectGlobs: []string{"package.json"}, + }, + { + Key: "mcp", Label: "MCP Server", + Family: "node", Artifact: true, UpdateGenerator: "npm", FeedFormat: "none", + TemplateRepo: "Template-NPM", ManifestBased: true, PublishTarget: "npm", + DetectGlobs: []string{"**/mcp.json", "**/.mcp.json"}, + }, + { + Key: "generic", Label: "Generic", Family: "generic", Artifact: false, + UpdateGenerator: "none", FeedFormat: "none", TemplateRepo: "Template-Generic", + ManifestBased: false, PublishTarget: "git-release", + }, + } +} + +// buildDefaultRegistry indexes the built-in registry by key and then ensures a +// record exists for every key in the admin PLATFORM_OPTIONS list (synthesizing a +// generic default for any key without a built-in entry). This keeps the flat +// PLATFORM_OPTIONS list and the structured registry consistent. +func buildDefaultRegistry(options []string) map[string]*PlatformDef { + reg := make(map[string]*PlatformDef) + for _, def := range builtinRegistry() { + d := def + reg[d.Key] = d + } + for _, opt := range options { + k := strings.ToLower(strings.TrimSpace(opt)) + if k == "" { + continue + } + if _, ok := reg[k]; !ok { + reg[k] = genericPlatformDef(k) + } + } + return reg +} + +// loadPlatformRegistryFrom loads the structured platform registry. +// +// Storage: [metadata] PLATFORM_REGISTRY in app.ini. The value is EITHER an +// inline JSON array of PlatformDef objects, OR a path to a JSON file (relative +// paths are resolved against CustomPath, e.g. custom/platforms.json). When +// unset, the built-in default registry is used, merged with any extra keys from +// PLATFORM_OPTIONS. This keeps behavior unchanged for unconfigured instances and +// avoids a DB schema change (idiomatic: mirrors the existing app.ini + file-path +// config pattern used elsewhere in modules/setting). +func loadPlatformRegistryFrom(rootCfg ConfigProvider) { + // Base defaults, reconciled with the flat PLATFORM_OPTIONS list. + platformRegistry = buildDefaultRegistry(Metadata.PlatformOptions) + + raw := strings.TrimSpace(rootCfg.Section("metadata").Key("PLATFORM_REGISTRY").String()) + if raw == "" { + return + } + + var data []byte + if strings.HasPrefix(raw, "[") { + // Inline JSON array. + data = []byte(raw) + } else { + // Treat as a file path (resolve relative to CustomPath). + path := raw + if !filepath.IsAbs(path) { + path = filepath.Join(CustomPath, path) + } + b, err := os.ReadFile(path) + if err != nil { + // Non-fatal: keep the default registry so the forge still boots. + return + } + data = b + } + + var defs []*PlatformDef + if err := json.Unmarshal(data, &defs); err != nil { + // Non-fatal: malformed registry falls back to the default. + return + } + if len(defs) == 0 { + return + } + + // Replace the registry with the configured set, filling defaults per entry. + reg := make(map[string]*PlatformDef, len(defs)) + for _, def := range defs { + if def == nil { + continue + } + key := strings.ToLower(strings.TrimSpace(def.Key)) + if key == "" { + continue + } + def.Key = key + if def.Label == "" { + def.Label = key + } + if def.Family == "" { + def.Family = "generic" + } + if def.UpdateGenerator == "" { + def.UpdateGenerator = "none" + } + if def.FeedFormat == "" { + def.FeedFormat = "none" + } + reg[key] = def + } + if len(reg) > 0 { + platformRegistry = reg + } +} diff --git a/modules/structs/miscellaneous.go b/modules/structs/miscellaneous.go index 293ba99579..168f9803fa 100644 --- a/modules/structs/miscellaneous.go +++ b/modules/structs/miscellaneous.go @@ -113,6 +113,34 @@ type LicenseTemplateInfo struct { Body string `json:"body"` } +// Platform is a single platform capability record from the platform registry. +// It exposes the non-secret capability attributes CI, the update server, and the +// workflow sync tooling read instead of hardcoding per-platform behavior. +type Platform struct { + // Key is the stable lowercase platform identifier + Key string `json:"key"` + // Label is the human-readable display name + Label string `json:"label"` + // Aliases are alternate keys matched by prefix in the release gate + Aliases []string `json:"aliases,omitempty"` + // Family groups related platforms (joomla, dolibarr, node, go, generic, dot) + Family string `json:"family"` + // Artifact reports whether the platform produces a build artifact + Artifact bool `json:"artifact"` + // UpdateGenerator names the update-feed generator (joomla, dolibarr, npm, none, ...) + UpdateGenerator string `json:"update_generator"` + // FeedFormat is the update-feed wire format (xml, json, none) + FeedFormat string `json:"feed_format"` + // TemplateRepo is the workflow-template source repo + TemplateRepo string `json:"template_repo,omitempty"` + // ManifestBased reports whether a manifest is parsed for element/version + ManifestBased bool `json:"manifest_based"` + // PublishTarget names where artifacts are published + PublishTarget string `json:"publish_target,omitempty"` + // DetectGlobs are file-pattern globs used to auto-detect this platform + DetectGlobs []string `json:"detect_globs,omitempty"` +} + // APIError is an api error with a message type APIError struct { // Message contains the error description diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 9cac166ec7..c0f547a408 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1009,6 +1009,10 @@ func Routes() *web.Router { m.Get("/label/templates", misc.ListLabelTemplates) m.Get("/label/templates/{name}", misc.GetLabelTemplate) + // Platform registry (non-secret capability config read by CI / mokocli). + m.Get("/platforms", misc.ListPlatforms) + m.Get("/platforms/{key}", misc.GetPlatform) + m.Group("/settings", func() { m.Get("/ui", settings.GetGeneralUISettings) m.Get("/api", settings.GetGeneralAPISettings) diff --git a/routers/api/v1/misc/platforms.go b/routers/api/v1/misc/platforms.go new file mode 100644 index 0000000000..343428911a --- /dev/null +++ b/routers/api/v1/misc/platforms.go @@ -0,0 +1,78 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package misc + +import ( + "net/http" + "strings" + + "code.mokoconsulting.tech/MokoConsulting/MokoGIT/modules/setting" + api "code.mokoconsulting.tech/MokoConsulting/MokoGIT/modules/structs" + "code.mokoconsulting.tech/MokoConsulting/MokoGIT/services/context" +) + +// toAPIPlatform converts a setting.PlatformDef into its API representation. +func toAPIPlatform(def *setting.PlatformDef) *api.Platform { + return &api.Platform{ + Key: def.Key, + Label: def.Label, + Aliases: def.Aliases, + Family: def.Family, + Artifact: def.Artifact, + UpdateGenerator: def.UpdateGenerator, + FeedFormat: def.FeedFormat, + TemplateRepo: def.TemplateRepo, + ManifestBased: def.ManifestBased, + PublishTarget: def.PublishTarget, + DetectGlobs: def.DetectGlobs, + } +} + +// ListPlatforms returns the whole platform registry. +func ListPlatforms(ctx *context.APIContext) { + // swagger:operation GET /platforms miscellaneous listPlatforms + // --- + // summary: Returns the platform registry (all platform capability records) + // produces: + // - application/json + // responses: + // "200": + // "$ref": "#/responses/PlatformList" + defs := setting.PlatformList() + response := make([]*api.Platform, len(defs)) + for i, def := range defs { + response[i] = toAPIPlatform(def) + } + ctx.JSON(http.StatusOK, response) +} + +// GetPlatform returns a single platform record by key. +func GetPlatform(ctx *context.APIContext) { + // swagger:operation GET /platforms/{key} miscellaneous getPlatform + // --- + // summary: Returns a single platform capability record by key + // produces: + // - application/json + // parameters: + // - name: key + // in: path + // description: platform key + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/Platform" + // "404": + // "$ref": "#/responses/notFound" + key := strings.ToLower(strings.TrimSpace(ctx.PathParam("key"))) + def, ok := setting.GetPlatform(key) + if !ok { + // Unknown key: only 404 when it is not an admin-listed platform. + // (GetPlatform synthesizes a generic default for unknown keys, but the + // endpoint should be honest about what is actually configured.) + ctx.APIErrorNotFound() + return + } + ctx.JSON(http.StatusOK, toAPIPlatform(def)) +} diff --git a/routers/api/v1/repo/manifest.go b/routers/api/v1/repo/manifest.go index b52eee7f71..9372bb5402 100644 --- a/routers/api/v1/repo/manifest.go +++ b/routers/api/v1/repo/manifest.go @@ -33,6 +33,14 @@ type apiMetadata struct { ExtensionType string `json:"extension_type"` EntryPoint string `json:"entry_point"` + // npm/mcp + NodeMinimum string `json:"node_minimum,omitempty"` + NpmPackage string `json:"npm_package,omitempty"` + NpmScope string `json:"npm_scope,omitempty"` + RegistryURL string `json:"registry_url,omitempty"` + Bin string `json:"bin,omitempty"` + PublishTarget string `json:"publish_target,omitempty"` + // deploy DeployHost string `json:"deploy_host,omitempty"` DeployPort string `json:"deploy_port,omitempty"` @@ -109,6 +117,12 @@ func GetRepoMetadata(ctx *context.APIContext) { Language: m.Language, ExtensionType: m.ExtensionType, EntryPoint: m.EntryPoint, + NodeMinimum: m.NodeMinimum, + NpmPackage: m.NpmPackage, + NpmScope: m.NpmScope, + RegistryURL: m.RegistryURL, + Bin: m.Bin, + PublishTarget: m.PublishTarget, DeployHost: m.DeployHost, DeployPort: m.DeployPort, DeployUser: m.DeployUser, @@ -188,6 +202,12 @@ func UpdateRepoMetadata(ctx *context.APIContext) { setStr("language", &m.Language) setStr("extension_type", &m.ExtensionType) setStr("entry_point", &m.EntryPoint) + setStr("node_minimum", &m.NodeMinimum) + setStr("npm_package", &m.NpmPackage) + setStr("npm_scope", &m.NpmScope) + setStr("registry_url", &m.RegistryURL) + setStr("bin", &m.Bin) + setStr("publish_target", &m.PublishTarget) setStr("deploy_host", &m.DeployHost) setStr("deploy_port", &m.DeployPort) setStr("deploy_user", &m.DeployUser) @@ -223,6 +243,12 @@ func UpdateRepoMetadata(ctx *context.APIContext) { Language: m.Language, ExtensionType: m.ExtensionType, EntryPoint: m.EntryPoint, + NodeMinimum: m.NodeMinimum, + NpmPackage: m.NpmPackage, + NpmScope: m.NpmScope, + RegistryURL: m.RegistryURL, + Bin: m.Bin, + PublishTarget: m.PublishTarget, DeployHost: m.DeployHost, DeployPort: m.DeployPort, DeployUser: m.DeployUser, diff --git a/routers/api/v1/swagger/misc.go b/routers/api/v1/swagger/misc.go index a16453a03a..3b201e4fb4 100644 --- a/routers/api/v1/swagger/misc.go +++ b/routers/api/v1/swagger/misc.go @@ -62,3 +62,17 @@ type swaggerResponseLabelTemplateInfo struct { // in:body Body []api.LabelTemplate `json:"body"` } + +// PlatformList +// swagger:response PlatformList +type swaggerResponsePlatformList struct { + // in:body + Body []api.Platform `json:"body"` +} + +// Platform +// swagger:response Platform +type swaggerResponsePlatform struct { + // in:body + Body api.Platform `json:"body"` +} diff --git a/routers/web/admin/metadata.go b/routers/web/admin/metadata.go index b55f7c0389..be13d37913 100644 --- a/routers/web/admin/metadata.go +++ b/routers/web/admin/metadata.go @@ -20,6 +20,10 @@ func Metadata(ctx *context.Context) { ctx.Data["Title"] = "Metadata" ctx.Data["PageIsAdminMetadata"] = true ctx.Data["PlatformOptions"] = strings.Join(setting.Metadata.PlatformOptions, "\n") + // Resolved platform registry (read-only view). Capability attributes are + // edited via app.ini [metadata] PLATFORM_REGISTRY; the textarea above still + // edits the flat key list (PLATFORM_OPTIONS). + ctx.Data["PlatformRegistry"] = setting.PlatformList() ctx.HTML(http.StatusOK, tplMetadata) } 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) } diff --git a/services/release/packaging.go b/services/release/packaging.go new file mode 100644 index 0000000000..94d5eab28c --- /dev/null +++ b/services/release/packaging.go @@ -0,0 +1,241 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package release + +import ( + "context" + "fmt" + "io" + "path" + "strings" + + repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGIT/models/repo" + user_model "code.mokoconsulting.tech/MokoConsulting/MokoGIT/models/user" + "code.mokoconsulting.tech/MokoConsulting/MokoGIT/modules/gitrepo" + "code.mokoconsulting.tech/MokoConsulting/MokoGIT/modules/globallock" + "code.mokoconsulting.tech/MokoConsulting/MokoGIT/modules/log" + attachment_service "code.mokoconsulting.tech/MokoConsulting/MokoGIT/services/attachment" +) + +// generatedArtifactUploaderID marks a release attachment as server-generated by +// the packaging hook rather than uploaded by a human. Real uploads always set +// UploaderID to a positive doer ID (see routers/**/release_attachment.go), so +// this negative built-in bot id can never collide with a user asset. Only +// attachments carrying this marker are ever deleted by the packaging hook, which +// prevents it from destroying a user-uploaded asset that happens to share a name +// with a generated zip. +const generatedArtifactUploaderID = user_model.ActionsUserID + +// getReleasePackagingLockKey builds the global-lock key that serializes the +// delete+attach sequence for a single release, following the forge convention +// (see getPullWorkingLockKey / getWikiWorkingLockKey). +func getReleasePackagingLockKey(relID int64) string { + return fmt.Sprintf("release_packaging_%d", relID) +} + +// isRootEntryPoint reports whether an entry point refers to the repository root +// (and therefore does not warrant a separate subtree archive). +func isRootEntryPoint(entryPoint string) bool { + switch normalizeEntryPoint(entryPoint) { + case "", ".": + return true + default: + return false + } +} + +// normalizeEntryPoint reduces an entry point to a repo-relative, cleaned path +// suitable for `git archive `. It trims surrounding whitespace, +// strips a leading slash (git archive rejects absolute paths), drops a trailing +// slash, and cleans the result. A root-equivalent entry point normalizes to "". +func normalizeEntryPoint(entryPoint string) string { + ep := strings.TrimSpace(entryPoint) + ep = strings.TrimPrefix(ep, "/") + ep = strings.TrimSuffix(ep, "/") + if ep == "" { + return "" + } + ep = path.Clean(ep) + if ep == "." || ep == "/" { + return "" + } + return ep +} + +// sanitizeNameSegment makes a string safe and tidy for use inside an attachment +// display name. Attachment storage paths are UUID-based (see +// AttachmentRelativePath), so there is no traversal risk — this only keeps the +// display name clean, e.g. a tag "release/1.0" would otherwise render as +// "repo-release/1.0.zip". +func sanitizeNameSegment(s string) string { + return strings.NewReplacer( + "/", "-", + "\\", "-", + "\x00", "-", + ).Replace(s) +} + +// findGeneratedArtifacts returns the existing, server-generated attachments on +// the release with the given name. User-uploaded assets (any UploaderID other +// than the marker) are never included, so they can never be touched. +func findGeneratedArtifacts(rel *repo_model.Release, name string) []*repo_model.Attachment { + var out []*repo_model.Attachment + for _, a := range rel.Attachments { + if a.Name == name && a.UploaderID == generatedArtifactUploaderID { + out = append(out, a) + } + } + return out +} + +// attachArchive streams a `git archive` of the given commit/paths directly into +// a new release attachment via an io.Pipe, so the full zip is never buffered in +// memory. paths == nil archives the whole repository; a single-element slice +// archives that subtree. The attachment is tagged with the generated-artifact +// marker so a later run can safely recognize and replace it. +func attachArchive(ctx context.Context, rel *repo_model.Release, name, commitID string, paths []string) error { + pr, pw := io.Pipe() + + go func() { + // "zip" is a valid archive format for gitrepo.CreateArchive (it only + // rejects "unknown"). usePrefix=false keeps entries at the archive root. + err := gitrepo.CreateArchive(ctx, rel.Repo, "zip", pw, false, commitID, paths) + // Closing the writer with err (nil on success) propagates any archive + // failure to the reader side so NewAttachment aborts instead of storing + // a truncated zip. + _ = pw.CloseWithError(err) + }() + + attach := &repo_model.Attachment{ + RepoID: rel.RepoID, + ReleaseID: rel.ID, + UploaderID: generatedArtifactUploaderID, + Name: name, + } + + // size == -1: the archive is streamed with an unknown length, matching the + // unknown-size upload path already used by the attachment service. + if _, err := attachment_service.NewAttachment(ctx, attach, pr, -1); err != nil { + _ = pr.CloseWithError(err) + return fmt.Errorf("NewAttachment %s: %w", name, err) + } + return nil +} + +// regenerateArtifact attaches a fresh archive under the canonical name and only +// then, on success, removes any previously generated copies of that name +// (attach-new-then-delete-old). This ordering guarantees a failed regeneration +// never nets asset loss: if the archive fails, the previous artifact is left +// intact and nothing is deleted. On success the stale generated copies are +// removed, leaving exactly one generated artifact under the name. +// +// Only artifacts carrying the generated-artifact marker are considered for +// deletion, so a user-uploaded asset of the same name is never removed. The +// caller must hold the per-release packaging lock so the brief window in which +// two same-named generated copies coexist is never observed concurrently. +func regenerateArtifact(ctx context.Context, rel *repo_model.Release, name, commitID string, paths []string) error { + // Snapshot prior generated copies BEFORE attaching the new one, so the + // post-attach cleanup removes only the old rows, not the fresh insert. + stale := findGeneratedArtifacts(rel, name) + + if err := attachArchive(ctx, rel, name, commitID, paths); err != nil { + // Attach failed — leave any existing artifact untouched. No net loss. + return err + } + + // New artifact is stored under the canonical name; drop the stale copies. + for _, old := range stale { + if err := repo_model.DeleteAttachment(ctx, old, true); err != nil { + // Non-fatal: log and continue. Worst case a superseded copy lingers, + // but the fresh artifact is already in place under the real name. + log.Warn("Failed to delete superseded release artifact %s (uuid %s): %v", old.Name, old.UUID, err) + } + } + return nil +} + +// GenerateReleaseArtifacts packages a published release into release +// attachments: a full-repository zip always, plus an entry-point subtree zip +// when the repository metadata declares a non-root build entry point. +// +// It is idempotent and safe on re-run: only server-generated artifacts (tagged +// with the generated-artifact marker) are ever replaced, so a user-uploaded +// asset sharing a name is never destroyed. The delete+attach sequence for the +// release is serialized under a global lock, and each artifact is attached +// before its stale copy is removed, so concurrent runs and mid-run failures can +// never net an asset loss. +// +// It is intended to run BEFORE GenerateReleaseChecksums so the subsequent +// checksum pass mints a .sha256 sidecar for each generated zip automatically. +// +// Following the convention of GenerateReleaseChecksums, a failure to produce an +// individual artifact is logged and does not abort the release; only setup +// failures (metadata lookup, attachment enumeration, lock acquisition) are +// returned. +func GenerateReleaseArtifacts(ctx context.Context, rel *repo_model.Release) error { + if rel.Repo == nil { + if err := rel.LoadAttributes(ctx); err != nil { + return fmt.Errorf("LoadAttributes: %w", err) + } + } + + commitID := rel.Sha1 + if commitID == "" { + // No resolved commit (e.g. a draft with no tag) — nothing to archive. + return nil + } + + version := sanitizeNameSegment(rel.TagName) + repoName := rel.Repo.Name + + fullName := fmt.Sprintf("%s-%s.zip", repoName, version) + sourceName := fmt.Sprintf("%s-%s-source.zip", repoName, version) + + // Resolve the build entry point from repo metadata, if any. + var entryPoint string + meta, err := repo_model.GetRepoMetadata(ctx, rel.RepoID) + if err != nil { + log.Warn("GenerateReleaseArtifacts: GetRepoMetadata for repo %d: %v", rel.RepoID, err) + } else if meta != nil { + entryPoint = meta.EntryPoint + } + wantSource := !isRootEntryPoint(entryPoint) + + // Serialize the delete+attach sequence per-release so concurrent + // CreateRelease/UpdateRelease calls on the same release cannot interleave + // (which could duplicate zips or delete a freshly attached artifact). + return globallock.LockAndDo(ctx, getReleasePackagingLockKey(rel.ID), func(ctx context.Context) error { + // Re-load attachments inside the lock so replace decisions see a + // consistent view (including any generated artifacts from a prior run). + if err := repo_model.GetReleaseAttachments(ctx, rel); err != nil { + return fmt.Errorf("GetReleaseAttachments: %w", err) + } + + created := 0 + + // Full-repository archive (paths == nil). + if err := regenerateArtifact(ctx, rel, fullName, commitID, nil); err != nil { + log.Error("GenerateReleaseArtifacts: full-repo zip for release %s (repo %d): %v", version, rel.RepoID, err) + } else { + created++ + } + + // Entry-point subtree archive. The stale-snapshot is taken from the same + // in-lock attachment read above; the full-repo archive uses a different + // name, so no re-read is needed here. + if wantSource { + subPath := normalizeEntryPoint(entryPoint) + if err := regenerateArtifact(ctx, rel, sourceName, commitID, []string{subPath}); err != nil { + log.Error("GenerateReleaseArtifacts: subtree zip (%s) for release %s (repo %d): %v", subPath, version, rel.RepoID, err) + } else { + created++ + } + } + + if created > 0 { + log.Info("Generated %d release artifact(s) for release %s (repo %d)", created, version, rel.RepoID) + } + return nil + }) +} diff --git a/services/release/release.go b/services/release/release.go index fc71bca91c..19a5f1f7a0 100644 --- a/services/release/release.go +++ b/services/release/release.go @@ -254,8 +254,20 @@ func CreateRelease(gitRepo *git.Repository, rel *repo_model.Release, attachmentU return err } - // Generate SHA256 checksums for all release attachments - if len(attachmentUUIDs) > 0 { + // Server-side packaging: attach full-repo + entry_point subtree zips. + // Skip drafts (mirrors the notify guard below); run before the checksum + // pass so the generated zips receive .sha256 sidecars automatically. + if !rel.IsDraft { + if err := GenerateReleaseArtifacts(gitRepo.Ctx, rel); err != nil { + log.Error("GenerateReleaseArtifacts for %s: %v", rel.TagName, err) + } + } + + // Generate SHA256 checksums for all release attachments. + // FOLLOW-UP (#809): GenerateReleaseChecksums re-hashes every attachment on + // the release, so publishing a release with many large assets re-reads them + // all. Consider hashing only newly added/changed attachments. + if len(attachmentUUIDs) > 0 || !rel.IsDraft { if err := GenerateReleaseChecksums(gitRepo.Ctx, rel); err != nil { log.Error("GenerateReleaseChecksums for %s: %v", rel.TagName, err) } @@ -415,8 +427,21 @@ func UpdateRelease(ctx context.Context, doer *user_model.User, gitRepo *git.Repo } } - // Regenerate checksums when attachments change - if len(addAttachmentUUIDs) > 0 || len(delAttachmentUUIDs) > 0 { + // Server-side packaging on publish: (re)attach full-repo + entry_point + // subtree zips. Skip drafts (mirrors the notify guard below). The helper is + // idempotent, so a draft→publish transition replaces any prior artifacts. + // Run before the checksum pass so the zips receive .sha256 sidecars. + if !rel.IsDraft { + if err := GenerateReleaseArtifacts(ctx, rel); err != nil { + log.Error("GenerateReleaseArtifacts for %s: %v", rel.TagName, err) + } + } + + // Regenerate checksums when attachments change or artifacts were generated. + // FOLLOW-UP (#809): GenerateReleaseChecksums re-hashes every attachment on + // the release, so a non-draft edit re-reads them all even if only one asset + // changed. Consider hashing only newly added/changed attachments. + if len(addAttachmentUUIDs) > 0 || len(delAttachmentUUIDs) > 0 || !rel.IsDraft { if err := GenerateReleaseChecksums(ctx, rel); err != nil { log.Error("GenerateReleaseChecksums for %s: %v", rel.TagName, err) } diff --git a/services/updateserver/registry.go b/services/updateserver/registry.go new file mode 100644 index 0000000000..a840d01a99 --- /dev/null +++ b/services/updateserver/registry.go @@ -0,0 +1,75 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package updateserver + +import ( + "strings" + + "code.mokoconsulting.tech/MokoConsulting/MokoGIT/modules/setting" +) + +// This file rewires the update server's per-platform behavior onto the platform +// registry (modules/setting) so that the update-feed generator and wire format +// for a platform are looked up from config instead of hardcoded. +// +// Overlap note (for reviewer): PR #825 (feature/updateserver-npm-mcp) also +// touches routers/web/repo/updateserver.go and adds models/updateserver/ +// platform.go (platform constants + ResolvePlatform + IsFeedlessPlatform). To +// stay compatible and avoid a heavy conflict, this change is intentionally +// ADDITIVE: it adds registry lookups here rather than rewriting the Serve* +// handler bodies. The helpers below are drop-in replacements for the hardcoded +// per-platform decisions and can be adopted by the handlers once #825 lands. + +// GeneratorForPlatform returns the update-feed generator name for a platform +// key, read from the registry. Falls back to "none" for unknown platforms. +// +// This replaces the hardcoded per-platform generator selection: instead of a +// switch on the platform string, callers read registry[platform].UpdateGenerator. +func GeneratorForPlatform(platform string) string { + def, _ := setting.GetPlatform(platform) + if def.UpdateGenerator == "" { + return "none" + } + return def.UpdateGenerator +} + +// FeedFormatForPlatform returns the update-feed wire format (xml|json|none) for a +// platform key, read from the registry. Content-Type is derived from this. +func FeedFormatForPlatform(platform string) string { + def, _ := setting.GetPlatform(platform) + if def.FeedFormat == "" { + return "none" + } + return def.FeedFormat +} + +// ContentTypeForPlatform returns the HTTP Content-Type for a platform's update +// feed, derived from the registry feed_format. Empty string when the platform +// emits no feed (feed_format=none), letting callers 404. +func ContentTypeForPlatform(platform string) string { + switch FeedFormatForPlatform(platform) { + case "xml": + return "application/xml; charset=utf-8" + case "json": + return "application/json; charset=utf-8" + default: + return "" + } +} + +// PlatformServesGenerator reports whether the given platform key resolves (via +// the registry) to the named update-feed generator. This lets a Serve* handler +// gate on capability config instead of a hardcoded string compare, e.g. +// +// if !PlatformServesGenerator(platform, "joomla") { ctx.NotFound(nil); return } +// +// The empty platform string is treated as "joomla" for backwards compatibility +// with legacy Joomla repos that predate the registry (mirrors the historic +// "empty ⇒ joomla" default; PR #825's ResolvePlatform centralizes this). +func PlatformServesGenerator(platform, generator string) bool { + if strings.TrimSpace(platform) == "" { + platform = "joomla" + } + return GeneratorForPlatform(platform) == generator +} diff --git a/templates/admin/metadata.tmpl b/templates/admin/metadata.tmpl index e9061e36d9..b3dd6879e9 100644 --- a/templates/admin/metadata.tmpl +++ b/templates/admin/metadata.tmpl @@ -15,5 +15,40 @@

Existing repositories keep their stored platform value even if it is later removed from this list; it simply won't be offered to other repos.

+ +

+ {{svg "octicon-table" 16}} Platform Registry +

+
+

Resolved capability attributes per platform. These drive CI, the update server, and workflow sync (exposed at GET /api/v1/platforms). Capabilities are edited via app.ini [metadata] PLATFORM_REGISTRY (inline JSON array or a path to a JSON file such as custom/platforms.json); when unset, a built-in default registry is used.

+ + + + + + + + + + + + + + + {{range .PlatformRegistry}} + + + + + + + + + + + {{end}} + +
KeyLabelFamilyArtifactUpdate GeneratorFeed FormatManifestTemplate Repo
{{.Key}}{{.Label}}{{.Family}}{{if .Artifact}}yes{{else}}no{{end}}{{.UpdateGenerator}}{{.FeedFormat}}{{if .ManifestBased}}yes{{else}}no{{end}}{{.TemplateRepo}}
+
{{template "admin/layout_footer" .}}