feat(updates): PrestaShop, Drupal, WHMCS update feeds (#352, #353, #355) #418

Merged
jmiller merged 1 commits from dev into main 2026-06-02 14:08:38 +00:00
5 changed files with 561 additions and 0 deletions
+92
View File
@@ -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(`<?xml version="1.0" encoding="UTF-8"?><modules></modules>`))
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(`<?xml version="1.0" encoding="UTF-8"?><project></project>`))
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)
}
+3
View File
@@ -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
+165
View File
@@ -0,0 +1,165 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// 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
}
+158
View File
@@ -0,0 +1,158 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// 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
}
+143
View File
@@ -0,0 +1,143 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// 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
}