528 lines
16 KiB
Go
528 lines
16 KiB
Go
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
// 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.GetRepoManifest(ctx, repo.ID)
|
|
if err != nil {
|
|
log.Error("resolveExtensionMetadata: GetRepoManifest for repo %d: %v", repo.ID, err)
|
|
}
|
|
if manifest != nil {
|
|
if elem := manifest.FullElementName(); elem != "" {
|
|
m.Element = elem
|
|
}
|
|
if manifest.PackageType != "" {
|
|
m.ExtType = manifest.PackageType
|
|
}
|
|
if manifest.DisplayName != "" {
|
|
m.DisplayName = manifest.DisplayName
|
|
}
|
|
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 <client> 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: "<hex> <filename>\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
|
|
}
|