Files
MokoGitea/services/updateserver/joomla.go
T
Jonathan Miller 8e0388c9d8
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Access control (pull_request) Successful in 2s
PR RC Release / Build RC Release (pull_request) Successful in 3s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
fix(custom-fields): log errors instead of silently discarding them
- saveCustomFieldsFromForm: log GetCustomFieldsByOwner errors
- resolveExtensionMetadata: log DB errors on custom field lookup
- NewIssue/ViewIssue: log errors from GetCustomFieldsByOwner and
  GetCustomFieldValuesMap instead of blank-assigning
- Composer: fix misleading comment about override source

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 22:14:10 -05:00

549 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"
issues_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/issues"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses"
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 []licenses.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: custom field → config table → 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 with cascading fallback:
// org-level repo-scoped custom fields → update_stream_config → repo-derived defaults.
func resolveExtensionMetadata(ctx context.Context, repo *repo_model.Repository, cfg *licenses.UpdateStreamConfig) extensionMetadata {
m := extensionMetadata{
Element: strings.ToLower(repo.Name),
DisplayName: fmt.Sprintf("%s - %s", repo.Owner.Name, repo.Name),
ExtType: "component",
TargetVersion: "(5|6)\\..*",
}
// Apply config table values.
if cfg != nil {
if cfg.ExtensionName != "" {
m.Element = cfg.ExtensionName
}
if cfg.DisplayName != "" {
m.DisplayName = cfg.DisplayName
}
if cfg.ExtensionType != "" {
m.ExtType = cfg.ExtensionType
}
if cfg.TargetVersion != "" {
m.TargetVersion = cfg.TargetVersion
}
if cfg.PHPMinimum != "" {
m.PHPMinimum = cfg.PHPMinimum
}
if cfg.Description != "" {
m.Description = cfg.Description
}
if cfg.SupportURL != "" {
m.SupportURL = cfg.SupportURL
}
if cfg.DownloadGating != "" {
m.DownloadGating = cfg.DownloadGating
}
if cfg.KeyPrefix != "" {
m.KeyPrefix = cfg.KeyPrefix
}
}
// Override with custom field values (highest priority).
fields, err := issues_model.GetCustomFieldsByOwner(ctx, repo.OwnerID, issues_model.CustomFieldScopeRepo)
if err != nil {
log.Error("resolveExtensionMetadata: GetCustomFieldsByOwner for repo %d: %v", repo.ID, err)
return m
}
if len(fields) == 0 {
return m
}
values, err := issues_model.GetCustomFieldValuesMap(ctx, repo.ID)
if err != nil {
log.Error("resolveExtensionMetadata: GetCustomFieldValuesMap for repo %d: %v", repo.ID, err)
return m
}
if len(values) == 0 {
return m
}
// Build name → value map from field definitions + values.
named := make(map[string]string, len(fields))
for _, f := range fields {
if v, ok := values[f.ID]; ok && v != "" {
named[f.Name] = v
}
}
if v := named["Extension Name"]; v != "" {
m.Element = v
}
if v := named["Display Name"]; v != "" {
m.DisplayName = v
}
if v := named["Extension Type"]; v != "" {
m.ExtType = v
}
if v := named["Target Version"]; v != "" {
m.TargetVersion = v
}
if v := named["PHP Minimum"]; v != "" {
m.PHPMinimum = v
}
if v := named["Support URL"]; v != "" {
m.SupportURL = v
}
if v := named["Description"]; v != "" {
m.Description = v
}
if v := named["Download Gating"]; v != "" {
m.DownloadGating = v
}
if v := named["Key Prefix"]; v != "" {
m.KeyPrefix = v
}
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 := licenses.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 := licenses.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 := licenses.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 {
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
}
suffix := stream.Suffix
if suffix == "" {
suffix = channelSuffix(ch) // fallback for Joomla defaults
}
if suffix != "" {
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: packages use client_id=0 in #__extensions,
// so we must output <client>0</client> for Joomla to match the update
// to the installed extension. Other types default to "site" (client_id=0)
// or "administrator" (client_id=1).
client := "site"
if extType == "package" {
client = "0"
}
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 like 1.0.0, 02.29.04, etc.
var versionRegex = regexp.MustCompile(`(\d+\.\d+(?:\.\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/")
// Strip channel suffixes.
for _, suffix := range []string{"-dev", "-alpha", "-beta", "-rc", "-development", "-release-candidate"} {
if idx := strings.Index(strings.ToLower(v), suffix); idx > 0 {
v = v[:idx]
break
}
}
// 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 ""
}
// 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
}