// Copyright 2026 Moko Consulting // SPDX-License-Identifier: GPL-3.0-or-later package updateserver import ( "context" "encoding/xml" "fmt" "strings" "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db" "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses" repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo" "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/setting" ) // Drupal update status XML structures. // See: https://www.drupal.org/docs/drupal-apis/update-status-module/update-status-xml-format type drupalProject struct { XMLName xml.Name `xml:"project"` Title string `xml:"title"` ShortName string `xml:"short_name"` APIVersion string `xml:"api_version"` RecommendedMaj string `xml:"recommended_major"` DefaultMajor string `xml:"default_major"` ProjectStatus string `xml:"project_status"` Link string `xml:"link"` Releases drupalReleases `xml:"releases"` } type drupalReleases struct { Releases []drupalRelease `xml:"release"` } type drupalRelease struct { Name string `xml:"name"` Version string `xml:"version"` Tag string `xml:"tag"` Status string `xml:"status"` ReleaseLink string `xml:"release_link"` DownloadURL string `xml:"download_link"` Date string `xml:"date"` FileHash string `xml:"mdhash,omitempty"` SHA256 string `xml:"sha256,omitempty"` } // GenerateDrupalXML builds a Drupal update status-compatible XML feed. func GenerateDrupalXML(ctx context.Context, repo *repo_model.Repository, 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("FindReleases: %w", err) } if err := repo.LoadOwner(ctx); err != nil { return nil, fmt.Errorf("LoadOwner: %w", err) } baseURL := strings.TrimSuffix(setting.AppURL, "/") repoLink := fmt.Sprintf("%s/%s/%s", baseURL, repo.Owner.Name, repo.Name) cfg := licenses.GetEffectiveConfig(ctx, repo.OwnerID, repo.ID) meta := resolveExtensionMetadata(ctx, repo, cfg) shortName := meta.Element title := meta.DisplayName streams := licenses.GetEffectiveStreams(ctx, repo.OwnerID, repo.ID) channelAllowed := make(map[string]bool) if len(allowedChannels) > 0 { for _, c := range allowedChannels { channelAllowed[NormalizeChannel(c)] = true } } project := drupalProject{ Title: title, ShortName: shortName, APIVersion: "7.x", // default API version RecommendedMaj: "1", DefaultMajor: "1", ProjectStatus: "published", Link: repoLink, } if cfg != nil && cfg.TargetVersion != "" { project.APIVersion = cfg.TargetVersion } for _, rel := range releases { if rel.IsDraft || rel.IsTag { continue } ch := licenses.ResolveReleaseStream(ctx, rel.ID, rel.TagName, rel.IsPrerelease, streams) if len(channelAllowed) > 0 && !channelAllowed[ch] { continue } if err := rel.LoadAttributes(ctx); err != nil { continue } var downloadURL, sha256Hash 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) break } } if downloadURL == "" { downloadURL = fmt.Sprintf("%s/archive/%s.zip", repoLink, rel.TagName) } for _, att := range rel.Attachments { if strings.HasSuffix(att.Name, ".sha256") { sha256Hash = readSHA256FromSidecar(ctx, att) break } } version := extractVersion(rel.TagName) if isStreamName(version, streams) || version == "" { version = extractVersion(rel.Title) } if version == "" { version = rel.TagName } status := "published" if rel.IsPrerelease { status = "insecure" // Drupal uses this for non-stable } project.Releases.Releases = append(project.Releases.Releases, drupalRelease{ Name: fmt.Sprintf("%s %s", shortName, version), Version: version, Tag: rel.TagName, Status: status, ReleaseLink: fmt.Sprintf("%s/releases/tag/%s", repoLink, rel.TagName), DownloadURL: downloadURL, Date: fmt.Sprintf("%d", rel.CreatedUnix), SHA256: sha256Hash, }) } output, err := xml.MarshalIndent(project, "", " ") if err != nil { return nil, fmt.Errorf("xml.MarshalIndent: %w", err) } return append([]byte(xml.Header), output...), nil }