// Copyright 2026 Moko Consulting // SPDX-License-Identifier: GPL-3.0-or-later package updateserver import ( "context" "encoding/xml" "fmt" "io" "regexp" "strings" "time" "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db" updateserver_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/updateserver" repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo" "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log" "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/storage" "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/setting" ) // Joomla-compatible updates.xml structures for XML marshaling. type xmlUpdates struct { XMLName xml.Name `xml:"updates"` Updates []xmlUpdate `xml:"update"` } type xmlUpdate struct { Name string `xml:"name"` Element string `xml:"element"` Type string `xml:"type"` Version string `xml:"version"` InfoURL xmlInfoURL `xml:"infourl"` Downloads xmlDownloads `xml:"downloads"` Tags xmlTags `xml:"tags"` TargetPlatform xmlTargetPlat `xml:"targetplatform"` SHA256 string `xml:"sha256,omitempty"` SHA512 string `xml:"sha512,omitempty"` Client string `xml:"client,omitempty"` PHPMinimum string `xml:"php_minimum,omitempty"` Description string `xml:"description,omitempty"` CreationDate string `xml:"creationDate,omitempty"` ChangelogURL string `xml:"changelogurl,omitempty"` Maintainer string `xml:"maintainer,omitempty"` MaintainerURL string `xml:"maintainerurl,omitempty"` DownloadKey *xmlDownloadKey `xml:"downloadkey,omitempty"` } type xmlDownloadKey struct { Prefix string `xml:"prefix,attr"` Suffix string `xml:"suffix,attr"` } type xmlInfoURL struct { Title string `xml:"title,attr"` URL string `xml:",chardata"` } type xmlDownloads struct { DownloadURL []xmlDownloadURL `xml:"downloadurl"` } type xmlDownloadURL struct { Type string `xml:"type,attr"` Format string `xml:"format,attr"` URL string `xml:",chardata"` } type xmlTags struct { Tag string `xml:"tag"` } type xmlTargetPlat struct { Name string `xml:"name,attr"` Version string `xml:"version,attr"` } // channelFromTag maps a release tag name to a Joomla update channel. // Joomla update stream names (full convention). const ( ChannelStable = "stable" ChannelReleaseCandidate = "release-candidate" ChannelBeta = "beta" ChannelAlpha = "alpha" ChannelDevelopment = "development" ) // AllChannels in display order (most stable first). var AllChannels = []string{ChannelStable, ChannelReleaseCandidate, ChannelBeta, ChannelAlpha, ChannelDevelopment} // channelFromTag maps a release tag name to a Joomla update channel. func channelFromTag(tagName string, isPrerelease bool) string { lower := strings.ToLower(tagName) switch { case strings.Contains(lower, "-dev") || strings.Contains(lower, "development"): return ChannelDevelopment case strings.Contains(lower, "-alpha"): return ChannelAlpha case strings.Contains(lower, "-beta"): return ChannelBeta case strings.Contains(lower, "-rc") || strings.Contains(lower, "release-candidate"): return ChannelReleaseCandidate case isPrerelease: return ChannelReleaseCandidate default: return ChannelStable } } // isStreamName checks if a string matches any stream name (indicating the tag // is a stream name, not a version number). func isStreamName(s string, streams []updateserver_model.StreamDef) bool { for _, st := range streams { if strings.EqualFold(st.Name, s) { return true } } return false } // joomlaTagName maps internal stream names to Joomla-standard tag values. // Joomla's Update.php maps tags via STABILITY_ + strtoupper(tag) constants. // Valid values: dev (0), alpha (1), beta (2), rc (3), stable (4). // Using full names like "development" or "release-candidate" would silently // fall back to STABILITY_STABLE, breaking pre-release channel filtering. func joomlaTagName(channel string) string { switch channel { case ChannelDevelopment: return "dev" case ChannelAlpha: return "alpha" case ChannelBeta: return "beta" case ChannelReleaseCandidate: return "rc" case ChannelStable: return "stable" default: return channel } } // NormalizeChannel maps shorthand channel names to the full Joomla convention. // Accepts both "rc" and "release-candidate", "dev" and "development", etc. func NormalizeChannel(ch string) string { switch strings.ToLower(ch) { case "rc", "release-candidate": return ChannelReleaseCandidate case "dev", "development": return ChannelDevelopment case "alpha": return ChannelAlpha case "beta": return ChannelBeta case "stable": return ChannelStable default: return ch } } // extensionMetadata holds resolved metadata for feed generation. // Fields are resolved with priority: manifest → config table (gating only) → default. type extensionMetadata struct { Element string DisplayName string ExtType string TargetVersion string PHPMinimum string Description string SupportURL string DownloadGating string KeyPrefix string } // resolveExtensionMetadata loads extension metadata from the repo manifest API. // The manifest is the single source of truth for extension identity fields. // The config table is only used for licensing/gating fields not in the manifest. func resolveExtensionMetadata(ctx context.Context, repo *repo_model.Repository, cfg *updateserver_model.UpdateStreamConfig) extensionMetadata { m := extensionMetadata{ Element: strings.ToLower(repo.Name), DisplayName: fmt.Sprintf("%s - %s", repo.Owner.Name, repo.Name), ExtType: "component", TargetVersion: "6\\..*", } // Manifest is the source of truth for extension metadata. manifest, err := repo_model.GetRepoMetadata(ctx, repo.ID) if err != nil { log.Error("resolveExtensionMetadata: GetRepoMetadata for repo %d: %v", repo.ID, err) } if manifest != nil { if elem := manifest.FullElementName(); elem != "" { m.Element = elem } if manifest.ExtensionType != "" { m.ExtType = manifest.ExtensionType } if dn := manifest.DerivedDisplayName(); dn != "" { m.DisplayName = dn } if manifest.TargetVersion != "" { m.TargetVersion = manifest.TargetVersion } if manifest.PHPMinimum != "" { m.PHPMinimum = manifest.PHPMinimum } if manifest.Description != "" { m.Description = manifest.Description } if manifest.InfoURL != "" { m.SupportURL = manifest.InfoURL } } // Config table: only licensing/gating fields (not in manifest). if cfg != nil { if cfg.DownloadGating != "" { m.DownloadGating = cfg.DownloadGating } if cfg.KeyPrefix != "" { m.KeyPrefix = cfg.KeyPrefix } // SupportURL from config as fallback if manifest.InfoURL is empty if m.SupportURL == "" && cfg.SupportURL != "" { m.SupportURL = cfg.SupportURL } } return m } // GenerateJoomlaXML builds a Joomla-compatible updates.xml from repository releases. // It returns the raw XML bytes. Extension metadata is resolved from custom fields first, // then the update stream config, then repo-derived defaults. // allowedChannels optionally restricts output to specific channels (nil = all). func GenerateJoomlaXML(ctx context.Context, repo *repo_model.Repository, requireKey, stripDownloads bool, allowedChannels ...string) ([]byte, error) { releases, err := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{ RepoID: repo.ID, ListOptions: db.ListOptionsAll, IncludeDrafts: false, IncludeTags: false, }) if err != nil { return nil, fmt.Errorf("GetReleasesByRepoID: %w", err) } if err := repo.LoadOwner(ctx); err != nil { return nil, fmt.Errorf("LoadOwner: %w", err) } baseURL := setting.AppURL if strings.HasSuffix(baseURL, "/") { baseURL = baseURL[:len(baseURL)-1] } repoLink := fmt.Sprintf("%s/%s/%s", baseURL, repo.Owner.Name, repo.Name) // Load extension metadata with cascading fallback: // custom fields → config table → repo-derived defaults. cfg := updateserver_model.GetEffectiveConfig(ctx, repo.OwnerID, repo.ID) meta := resolveExtensionMetadata(ctx, repo, cfg) element := meta.Element displayName := meta.DisplayName extType := meta.ExtType targetVersion := meta.TargetVersion phpMinimum := meta.PHPMinimum feedDescription := meta.Description // Maintainer and URL always come from the org profile. maintainer := repo.Owner.FullName if maintainer == "" { maintainer = repo.Owner.Name } maintainerURL := repo.Owner.Website if maintainerURL == "" { maintainerURL = fmt.Sprintf("%s/%s", baseURL, repo.Owner.Name) } // Resolve effective streams (repo override → org default → Joomla default). streams := updateserver_model.GetEffectiveStreams(ctx, repo.OwnerID, repo.ID) // Track best (latest) release per channel to emit one entry per channel. bestByChannel := make(map[string]*repo_model.Release) for _, rel := range releases { if rel.IsDraft || rel.IsTag { continue } ch := updateserver_model.ResolveReleaseStream(ctx, rel.ID, rel.TagName, rel.IsPrerelease, streams) existing, ok := bestByChannel[ch] if !ok || rel.CreatedUnix > existing.CreatedUnix { bestByChannel[ch] = rel } } // Build allowed channel set for filtering. // Normalize shorthand names so both "rc" and "release-candidate" work. channelAllowed := make(map[string]bool) if len(allowedChannels) > 0 { for _, c := range allowedChannels { channelAllowed[NormalizeChannel(c)] = true } } var updates xmlUpdates for _, stream := range streams { ch := stream.Name // Skip channels not in the allowed set (when filtering is active). if len(channelAllowed) > 0 && !channelAllowed[ch] { continue } rel, ok := bestByChannel[ch] if !ok { continue } // Load attachments for download URLs. if err := rel.LoadAttributes(ctx); err != nil { log.Error("GenerateJoomlaXML: LoadAttributes for release %d (tag %s): %v", rel.ID, rel.TagName, err) continue } // Find the first .zip attachment as the download URL, and its SHA256 sidecar. var downloadURL, sha256Hash string var zipName string for _, att := range rel.Attachments { if strings.HasSuffix(strings.ToLower(att.Name), ".zip") && !strings.HasSuffix(att.Name, ".sha256") { downloadURL = fmt.Sprintf("%s/releases/download/%s/%s", repoLink, rel.TagName, att.Name) zipName = att.Name break } } // Look for the SHA256 sidecar file. if zipName != "" { for _, att := range rel.Attachments { if att.Name == zipName+".sha256" { sha256Hash = readSHA256FromSidecar(ctx, att) break } } } // Fall back to the release tag archive if no zip attachment. if downloadURL == "" { downloadURL = fmt.Sprintf("%s/archive/%s.zip", repoLink, rel.TagName) } // Extract version: prefer asset filename (matches actual download), // then tag name, then release title. Only fall through when empty. version := "" if zipName != "" { version = extractVersion(zipName) } if version == "" { version = extractVersion(rel.TagName) } if version == "" { version = extractVersion(rel.Title) } // Last resort: use the tag name as-is. if version == "" { version = rel.TagName } // Append channel suffix only if the version doesn't already // contain one (e.g. "1.2.3-rc2" already has "-rc"). suffix := stream.Suffix if suffix == "" { suffix = channelSuffix(ch) } if suffix != "" && !versionHasChannelSuffix(version) { version = version + suffix } desc := feedDescription if desc == "" { desc = fmt.Sprintf("%s %s build.", displayName, ch) } // Info URL: use support_url (product page), fall back to releases page. infoURL := fmt.Sprintf("%s/releases", repoLink) if meta.SupportURL != "" { infoURL = meta.SupportURL } // Joomla element must match the client_id stored in #__extensions. // Joomla's update finder matches by (element, type, client_id, folder) — // a mismatch causes extension_id=0 and the update never shows. // // Joomla hardcodes client_id per extension type in the installer adapters: // component → client_id=1 (ComponentAdapter.php:900) // package → client_id=0 (PackageAdapter.php:548) // plugin → client_id=0 (PluginAdapter.php:492) // library → client_id=0 (LibraryAdapter.php:420) // file → client_id=0 (FileAdapter.php:422) // module → client_id from manifest (0=site, 1=admin) // template → client_id from manifest (0=site, 1=admin) client := "site" // default: client_id=0 switch extType { case "component": client = "administrator" // client_id=1 } u := xmlUpdate{ Name: displayName, Description: desc, Element: element, Type: extType, Client: client, Version: version, CreationDate: time.Unix(int64(rel.CreatedUnix), 0).Format("2006-01-02"), InfoURL: xmlInfoURL{ Title: displayName, URL: infoURL, }, Downloads: xmlDownloads{ DownloadURL: func() []xmlDownloadURL { if stripDownloads { return nil } return []xmlDownloadURL{{Type: "full", Format: "zip", URL: downloadURL}} }(), }, Tags: xmlTags{Tag: joomlaTagName(ch)}, ChangelogURL: fmt.Sprintf("%s/changelog.xml", repoLink), Maintainer: maintainer, MaintainerURL: maintainerURL, PHPMinimum: phpMinimum, SHA256: sha256Hash, TargetPlatform: xmlTargetPlat{ Name: "joomla", Version: targetVersion, }, } if requireKey { u.DownloadKey = &xmlDownloadKey{Prefix: "dlid=", Suffix: ""} } updates.Updates = append(updates.Updates, u) } output, err := xml.MarshalIndent(updates, "", " ") if err != nil { return nil, fmt.Errorf("xml.MarshalIndent: %w", err) } return append([]byte(xml.Header), output...), nil } // versionRegex matches semantic version patterns with optional pre-release // suffixes, e.g. 1.0.0, 02.29.04, 1.2.3-rc2, 1.0.0-beta1. var versionRegex = regexp.MustCompile(`(\d+\.\d+(?:\.\d+)?(?:-(?:dev|alpha|beta|rc)\d*)?)`) // extractVersion finds a version number from a tag name or release title. // Tries: (1) strip common prefixes for version-style tags, (2) regex match for embedded versions. func extractVersion(s string) string { // Try prefix stripping first (works for "v1.0.0", "release-1.0.0"). v := s v = strings.TrimPrefix(v, "v") v = strings.TrimPrefix(v, "release-") v = strings.TrimPrefix(v, "release/") // Do not strip channel suffixes (e.g. -rc2, -beta1) here. // The caller appends stream suffixes only when the version // doesn't already contain one, preserving the original // pre-release number for correct Joomla version comparison. // If result looks like a version (starts with digit), use it. if len(v) > 0 && v[0] >= '0' && v[0] <= '9' { return strings.TrimSpace(v) } // Fallback: extract version pattern from anywhere in the string. if m := versionRegex.FindString(s); m != "" { return m } return "" } // channelSuffixRegex matches a pre-release channel suffix at the end of a // version string, e.g. "-rc", "-rc2", "-beta1", "-dev". Anchored to avoid // false positives like "-devtools". var channelSuffixRegex = regexp.MustCompile(`(?i)-(dev|alpha|beta|rc)\d*$`) // versionHasChannelSuffix checks if a version string already ends with a // pre-release channel suffix (e.g. "-rc2", "-beta1", "-dev"). func versionHasChannelSuffix(version string) bool { return channelSuffixRegex.MatchString(version) } // channelSuffix returns the version suffix for a channel. func channelSuffix(channel string) string { switch channel { case ChannelDevelopment: return "-dev" case ChannelAlpha: return "-alpha" case ChannelBeta: return "-beta" case ChannelReleaseCandidate: return "-rc" default: return "" } } // readSHA256FromSidecar reads the SHA256 hash from a .sha256 sidecar attachment. // The sidecar format is: " \n" func readSHA256FromSidecar(_ context.Context, att *repo_model.Attachment) string { fr, err := storage.Attachments.Open(att.RelativePath()) if err != nil { return "" } defer fr.Close() data, err := io.ReadAll(io.LimitReader(fr, 256)) if err != nil { return "" } content := strings.TrimSpace(string(data)) // Format: "hexhash filename" if idx := strings.Index(content, " "); idx > 0 { return content[:idx] } return content }