diff --git a/routers/web/repo/updateserver.go b/routers/web/repo/updateserver.go new file mode 100644 index 0000000000..d26ca922b6 --- /dev/null +++ b/routers/web/repo/updateserver.go @@ -0,0 +1,25 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package repo + +import ( + "net/http" + + "git.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context" + "git.mokoconsulting.tech/MokoConsulting/MokoGitea/services/updateserver" +) + +// ServeUpdatesXML generates and serves a Joomla-compatible updates.xml +// from the repository's releases. +func ServeUpdatesXML(ctx *context.Context) { + xmlData, err := updateserver.GenerateJoomlaXML(ctx, ctx.Repo.Repository) + if err != nil { + ctx.ServerError("GenerateJoomlaXML", err) + return + } + + ctx.Resp.Header().Set("Content-Type", "application/xml; charset=utf-8") + ctx.Resp.WriteHeader(http.StatusOK) + _, _ = ctx.Resp.Write(xmlData) +} diff --git a/routers/web/web.go b/routers/web/web.go index 452ad77302..47929531c1 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1494,6 +1494,12 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { }, optSignIn, context.RepoAssignment, repo.MustBeNotEmpty, reqRepoReleaseReader) // end "/{username}/{reponame}": repo releases + // "/{username}/{reponame}": update server (Joomla-compatible updates.xml) + m.Group("/{username}/{reponame}", func() { + m.Get("/updates.xml", repo.ServeUpdatesXML) + }, optSignIn, context.RepoAssignment) + // end "/{username}/{reponame}": update server + m.Group("/{username}/{reponame}", func() { // to maintain compatibility with old attachments m.Get("/attachments/{uuid}", webAuth.AllowBasic, webAuth.AllowOAuth2, repo.GetAttachment) }, optSignIn, context.RepoAssignment) diff --git a/services/updateserver/joomla.go b/services/updateserver/joomla.go new file mode 100644 index 0000000000..c5669188da --- /dev/null +++ b/services/updateserver/joomla.go @@ -0,0 +1,224 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package updateserver + +import ( + "context" + "encoding/xml" + "fmt" + "strings" + "time" + + "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db" + repo_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo" + "git.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"` + Description string `xml:"description"` + Element string `xml:"element"` + Type string `xml:"type"` + Client string `xml:"client"` + Version string `xml:"version"` + CreationDate string `xml:"creationDate"` + InfoURL xmlInfoURL `xml:"infourl"` + Downloads xmlDownloads `xml:"downloads"` + SHA256 string `xml:"sha256,omitempty"` + Tags xmlTags `xml:"tags"` + ChangelogURL string `xml:"changelogurl,omitempty"` + Maintainer string `xml:"maintainer,omitempty"` + MaintainerURL string `xml:"maintainerurl,omitempty"` + TargetPlatform xmlTargetPlat `xml:"targetplatform"` +} + +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. +func channelFromTag(tagName string, isPrerelease bool) string { + lower := strings.ToLower(tagName) + switch { + case strings.Contains(lower, "-dev") || strings.Contains(lower, "development"): + return "dev" + case strings.Contains(lower, "-alpha") || strings.Contains(lower, "alpha"): + return "alpha" + case strings.Contains(lower, "-beta") || strings.Contains(lower, "beta"): + return "beta" + case strings.Contains(lower, "-rc") || strings.Contains(lower, "release-candidate"): + return "rc" + case isPrerelease: + return "rc" + default: + return "stable" + } +} + +// GenerateJoomlaXML builds a Joomla-compatible updates.xml from repository releases. +// It returns the raw XML bytes. The element, maintainer, and target platform +// are derived from the repo name and owner. +func GenerateJoomlaXML(ctx context.Context, repo *repo_model.Repository) ([]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) + + element := strings.ToLower(repo.Name) + + // 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 := channelFromTag(rel.TagName, rel.IsPrerelease) + existing, ok := bestByChannel[ch] + if !ok || rel.CreatedUnix > existing.CreatedUnix { + bestByChannel[ch] = rel + } + } + + var updates xmlUpdates + for _, ch := range []string{"stable", "rc", "beta", "alpha", "dev"} { + 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. + var downloadURL string + for _, att := range rel.Attachments { + if strings.HasSuffix(strings.ToLower(att.Name), ".zip") { + downloadURL = fmt.Sprintf("%s/releases/download/%s/%s", repoLink, rel.TagName, att.Name) + break + } + } + // Fall back to the release tag archive if no zip attachment. + if downloadURL == "" { + downloadURL = fmt.Sprintf("%s/archive/%s.zip", repoLink, rel.TagName) + } + + version := extractVersion(rel.TagName) + suffix := channelSuffix(ch) + if suffix != "" { + version = version + suffix + } + + u := xmlUpdate{ + Name: fmt.Sprintf("%s - %s", repo.Owner.Name, repo.Name), + Description: fmt.Sprintf("%s - %s %s build.", repo.Owner.Name, repo.Name, ch), + Element: element, + Type: "component", + Client: "site", + Version: version, + CreationDate: time.Unix(int64(rel.CreatedUnix), 0).Format("2006-01-02"), + InfoURL: xmlInfoURL{ + Title: fmt.Sprintf("%s - %s", repo.Owner.Name, repo.Name), + URL: fmt.Sprintf("%s/releases/tag/%s", repoLink, rel.TagName), + }, + Downloads: xmlDownloads{ + DownloadURL: []xmlDownloadURL{ + {Type: "full", Format: "zip", URL: downloadURL}, + }, + }, + Tags: xmlTags{Tag: ch}, + ChangelogURL: fmt.Sprintf("%s/raw/branch/%s/CHANGELOG.md", repoLink, repo.DefaultBranch), + Maintainer: repo.Owner.Name, + MaintainerURL: fmt.Sprintf("%s/%s", baseURL, repo.Owner.Name), + TargetPlatform: xmlTargetPlat{ + Name: "joomla", + Version: ".*", + }, + } + + 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 +} + +// extractVersion strips common tag prefixes (v, release-, etc.) to get the version. +func extractVersion(tagName string) string { + v := tagName + v = strings.TrimPrefix(v, "v") + v = strings.TrimPrefix(v, "release-") + v = strings.TrimPrefix(v, "release/") + // Strip channel suffixes to get base version. + 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 + } + } + return v +} + +// channelSuffix returns the version suffix for a channel. +func channelSuffix(channel string) string { + switch channel { + case "dev": + return "-dev" + case "alpha": + return "-alpha" + case "beta": + return "-beta" + case "rc": + return "-rc" + default: + return "" + } +}