From 02f3ed88f17d55820f9130a1dd0503e542efdc6e Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 2 Jun 2026 09:08:03 -0500 Subject: [PATCH] feat(updates): PrestaShop (#352), Drupal (#353), WHMCS (#355) update feeds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PrestaShop: GET /updates/prestashop.xml — module update XML with name, version, download URL, author, SHA256. Serves stable only. Drupal: GET /updates/drupal.xml — update status XML per Drupal API spec. Includes project metadata, all releases with status, download links, SHA256. Uses TargetVersion config for api_version field. WHMCS: GET /updates/whmcs.json — simple JSON with latest stable version, download URL (with dlid), changelog, author. License key embedded in download URL when provided. All three use ResolveReleaseStream for manual/auto stream mapping, readSHA256FromSidecar for integrity hashes, and extractVersion with stream-name tag fallback. Routes registered under the update server group alongside Joomla, Dolibarr, WordPress, and Composer feeds. Co-Authored-By: Claude Opus 4.6 (1M context) --- routers/web/repo/updateserver.go | 92 ++++++++++++++++ routers/web/web.go | 3 + services/updateserver/drupal.go | 165 ++++++++++++++++++++++++++++ services/updateserver/prestashop.go | 158 ++++++++++++++++++++++++++ services/updateserver/whmcs.go | 143 ++++++++++++++++++++++++ 5 files changed, 561 insertions(+) create mode 100644 services/updateserver/drupal.go create mode 100644 services/updateserver/prestashop.go create mode 100644 services/updateserver/whmcs.go diff --git a/routers/web/repo/updateserver.go b/routers/web/repo/updateserver.go index 32e2809a7c..73c2bd75a4 100644 --- a/routers/web/repo/updateserver.go +++ b/routers/web/repo/updateserver.go @@ -249,3 +249,95 @@ func ServeComposerJSON(ctx *context.Context) { ctx.Resp.WriteHeader(http.StatusOK) _, _ = ctx.Resp.Write(jsonData) } + +// ServePrestaShopXML generates and serves a PrestaShop module update XML. +func ServePrestaShopXML(ctx *context.Context) { + platform := ctx.Data["RepoUpdatePlatform"] + if platform != "prestashop" { + ctx.NotFound(nil) + return + } + + allowedChannels, ok, _ := validateUpdateKey(ctx) + if !ok { + ctx.Resp.Header().Set("Content-Type", "application/xml; charset=utf-8") + ctx.Resp.WriteHeader(http.StatusOK) + _, _ = ctx.Resp.Write([]byte(``)) + return + } + + xmlData, err := updateserver.GeneratePrestaShopXML(ctx, ctx.Repo.Repository, allowedChannels...) + if err != nil { + ctx.ServerError("GeneratePrestaShopXML", err) + return + } + + ctx.Resp.Header().Set("Content-Type", "application/xml; charset=utf-8") + ctx.Resp.WriteHeader(http.StatusOK) + _, _ = ctx.Resp.Write(xmlData) +} + +// ServeDrupalXML generates and serves a Drupal update status XML. +func ServeDrupalXML(ctx *context.Context) { + platform := ctx.Data["RepoUpdatePlatform"] + if platform != "drupal" { + ctx.NotFound(nil) + return + } + + allowedChannels, ok, _ := validateUpdateKey(ctx) + if !ok { + ctx.Resp.Header().Set("Content-Type", "application/xml; charset=utf-8") + ctx.Resp.WriteHeader(http.StatusOK) + _, _ = ctx.Resp.Write([]byte(``)) + return + } + + xmlData, err := updateserver.GenerateDrupalXML(ctx, ctx.Repo.Repository, allowedChannels...) + if err != nil { + ctx.ServerError("GenerateDrupalXML", err) + return + } + + ctx.Resp.Header().Set("Content-Type", "application/xml; charset=utf-8") + ctx.Resp.WriteHeader(http.StatusOK) + _, _ = ctx.Resp.Write(xmlData) +} + +// ServeWHMCSJSON generates and serves a WHMCS module update JSON. +func ServeWHMCSJSON(ctx *context.Context) { + platform := ctx.Data["RepoUpdatePlatform"] + if platform != "whmcs" { + ctx.NotFound(nil) + return + } + + _, ok, _ := validateUpdateKey(ctx) + if !ok { + ctx.Resp.Header().Set("Content-Type", "application/json; charset=utf-8") + ctx.Resp.WriteHeader(http.StatusOK) + _, _ = ctx.Resp.Write([]byte(`{"name":"","version":"0.0.0"}`)) + return + } + + licenseKey := ctx.FormString("key") + if licenseKey == "" { + licenseKey = ctx.FormString("dlid") + } + + data, err := updateserver.GenerateWHMCSJSON(ctx, ctx.Repo.Repository, licenseKey) + if err != nil { + ctx.ServerError("GenerateWHMCSJSON", err) + return + } + + jsonData, err := json.MarshalIndent(data, "", " ") + if err != nil { + ctx.ServerError("json.Marshal", err) + return + } + + ctx.Resp.Header().Set("Content-Type", "application/json; charset=utf-8") + ctx.Resp.WriteHeader(http.StatusOK) + _, _ = ctx.Resp.Write(jsonData) +} diff --git a/routers/web/web.go b/routers/web/web.go index 21d340fe8f..bf111c87f6 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1520,6 +1520,9 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Get("/updates/dolibarr.json", repo.ServeDolibarrJSON) m.Get("/updates/wordpress.json", repo.ServeWordPressJSON) m.Get("/updates/packages.json", repo.ServeComposerJSON) + m.Get("/updates/prestashop.xml", repo.ServePrestaShopXML) + m.Get("/updates/drupal.xml", repo.ServeDrupalXML) + m.Get("/updates/whmcs.json", repo.ServeWHMCSJSON) m.Get("/changelog.xml", repo.ServeChangelogXML) }, optSignIn, context.RepoAssignment) // end "/{username}/{reponame}": update server diff --git a/services/updateserver/drupal.go b/services/updateserver/drupal.go new file mode 100644 index 0000000000..92af91f128 --- /dev/null +++ b/services/updateserver/drupal.go @@ -0,0 +1,165 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package updateserver + +import ( + "context" + "encoding/xml" + "fmt" + "strings" + "time" + + "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) + shortName := strings.ToLower(repo.Name) + title := repo.Name + if cfg != nil { + if cfg.ExtensionName != "" { + shortName = cfg.ExtensionName + } + if cfg.DisplayName != "" { + title = cfg.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 +} diff --git a/services/updateserver/prestashop.go b/services/updateserver/prestashop.go new file mode 100644 index 0000000000..81442f756e --- /dev/null +++ b/services/updateserver/prestashop.go @@ -0,0 +1,158 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package updateserver + +import ( + "context" + "encoding/xml" + "fmt" + "strings" + "time" + + "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" +) + +// PrestaShop module update XML structures. + +type psUpdates struct { + XMLName xml.Name `xml:"modules"` + Modules []psModule `xml:"module"` +} + +type psModule struct { + Name string `xml:"name,attr"` + Version string `xml:"version,attr"` + DisplayName string `xml:"displayName"` + Description string `xml:"description,omitempty"` + Author string `xml:"author"` + Tab string `xml:"tab,omitempty"` + Download string `xml:"download"` + Date string `xml:"date"` + SHA256 string `xml:"sha256,omitempty"` +} + +// GeneratePrestaShopXML builds a PrestaShop-compatible module update XML. +func GeneratePrestaShopXML(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) + moduleName := strings.ToLower(repo.Name) + displayName := repo.Name + maintainer := repo.Owner.Name + description := "" + if cfg != nil { + if cfg.ExtensionName != "" { + moduleName = cfg.ExtensionName + } + if cfg.DisplayName != "" { + displayName = cfg.DisplayName + } + if cfg.Maintainer != "" { + maintainer = cfg.Maintainer + } + if cfg.Description != "" { + description = cfg.Description + } + } + + streams := licenses.GetEffectiveStreams(ctx, repo.OwnerID, repo.ID) + + // Channel filtering. + channelAllowed := make(map[string]bool) + if len(allowedChannels) > 0 { + for _, c := range allowedChannels { + channelAllowed[NormalizeChannel(c)] = true + } + } + + // Track best release 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 + } + } + + var result psUpdates + for _, stream := range streams { + ch := stream.Name + if len(channelAllowed) > 0 && !channelAllowed[ch] { + continue + } + rel, ok := bestByChannel[ch] + if !ok || ch != "stable" { + continue // PrestaShop typically only serves stable + } + + 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 + } + + result.Modules = append(result.Modules, psModule{ + Name: moduleName, + Version: version, + DisplayName: displayName, + Description: description, + Author: maintainer, + Download: downloadURL, + Date: time.Unix(int64(rel.CreatedUnix), 0).Format("2006-01-02"), + SHA256: sha256Hash, + }) + } + + output, err := xml.MarshalIndent(result, "", " ") + if err != nil { + return nil, fmt.Errorf("xml.MarshalIndent: %w", err) + } + + return append([]byte(xml.Header), output...), nil +} diff --git a/services/updateserver/whmcs.go b/services/updateserver/whmcs.go new file mode 100644 index 0000000000..a3a95a50cb --- /dev/null +++ b/services/updateserver/whmcs.go @@ -0,0 +1,143 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package updateserver + +import ( + "context" + "fmt" + "strings" + "time" + + "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" +) + +// WHMCS module update JSON structures. +// WHMCS marketplace modules check for updates via a simple JSON endpoint. + +type WHMCSUpdate struct { + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description,omitempty"` + DownloadURL string `json:"download_url,omitempty"` + Changelog string `json:"changelog,omitempty"` + ReleaseDate string `json:"release_date"` + Author string `json:"author,omitempty"` + AuthorURL string `json:"author_url,omitempty"` + SHA256 string `json:"sha256,omitempty"` +} + +// GenerateWHMCSJSON builds a WHMCS-compatible module update response. +func GenerateWHMCSJSON(ctx context.Context, repo *repo_model.Repository, licenseKey string) (*WHMCSUpdate, 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) + displayName := repo.Name + maintainer := repo.Owner.Name + maintainerURL := fmt.Sprintf("%s/%s", baseURL, repo.Owner.Name) + description := "" + if cfg != nil { + if cfg.DisplayName != "" { + displayName = cfg.DisplayName + } + if cfg.Maintainer != "" { + maintainer = cfg.Maintainer + } + if cfg.MaintainerURL != "" { + maintainerURL = cfg.MaintainerURL + } + if cfg.Description != "" { + description = cfg.Description + } + } + + streams := licenses.GetEffectiveStreams(ctx, repo.OwnerID, repo.ID) + + // Find latest stable release. + var latestStable *repo_model.Release + for _, rel := range releases { + if rel.IsDraft || rel.IsTag { + continue + } + ch := licenses.ResolveReleaseStream(ctx, rel.ID, rel.TagName, rel.IsPrerelease, streams) + if ch == "stable" { + if latestStable == nil || rel.CreatedUnix > latestStable.CreatedUnix { + latestStable = rel + } + } + } + + if latestStable == nil { + return &WHMCSUpdate{ + Name: displayName, + Version: "0.0.0", + }, nil + } + + if err := latestStable.LoadAttributes(ctx); err != nil { + return nil, fmt.Errorf("LoadAttributes: %w", err) + } + + var downloadURL, sha256Hash string + for _, att := range latestStable.Attachments { + if strings.HasSuffix(strings.ToLower(att.Name), ".zip") && !strings.HasSuffix(att.Name, ".sha256") { + downloadURL = fmt.Sprintf("%s/releases/download/%s/%s", repoLink, latestStable.TagName, att.Name) + break + } + } + if downloadURL == "" { + downloadURL = fmt.Sprintf("%s/archive/%s.zip", repoLink, latestStable.TagName) + } + if licenseKey != "" { + downloadURL += "?dlid=" + licenseKey + } + for _, att := range latestStable.Attachments { + if strings.HasSuffix(att.Name, ".sha256") { + sha256Hash = readSHA256FromSidecar(ctx, att) + break + } + } + + version := extractVersion(latestStable.TagName) + if isStreamName(version, streams) || version == "" { + version = extractVersion(latestStable.Title) + } + if version == "" { + version = latestStable.TagName + } + + changelog := "" + if latestStable.Note != "" { + changelog = latestStable.Note + } + + return &WHMCSUpdate{ + Name: displayName, + Version: version, + Description: description, + DownloadURL: downloadURL, + Changelog: changelog, + ReleaseDate: time.Unix(int64(latestStable.CreatedUnix), 0).Format("2006-01-02"), + Author: maintainer, + AuthorURL: maintainerURL, + SHA256: sha256Hash, + }, nil +} -- 2.52.0