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
+}