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
- 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>
549 lines
16 KiB
Go
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
|
|
}
|