From 627a22ee53aec2f8a64ac56f8eccb2ba3ac5e23c Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sat, 30 May 2026 13:09:47 -0500 Subject: [PATCH] feat(updates): license key system and Dolibarr endpoint (Phase 2-3) Add license key data model and Dolibarr update feed endpoint: License key system: - license_package table: subscription tiers with duration, max sites, repo scope (org-wide or specific repos), and allowed update channels - license_key table: individual keys with SHA-256 hashed storage, domain restriction, custom start/end dates, internal/master key flag - license_key_usage table: tracks update check activity per key - DB migration v335 creates all three tables Update server enhancements: - Dolibarr JSON endpoint at /{owner}/{repo}/updates/dolibarr.json - License key validation on update endpoints via ?key=MOKO-XXXX param - Channel filtering: packages restrict which update streams keys access - Invalid keys get empty XML response (Joomla-compatible "no updates") - Usage tracking records domain, IP, user agent, version on each check Key design decisions: - Org-level master keys: IsInternal=true, package RepoScope="all" - Keys stored as SHA-256 hashes, raw key only shown at creation - Packages define allowed channels (e.g. ["stable","rc"] for Pro tier) - MOKO-XXXX-XXXX-XXXX-XXXX format for license keys Ref #239 Co-Authored-By: Claude Opus 4.6 (1M context) --- models/licenses/license_key.go | 159 +++++++++++++++++++++++++++ models/licenses/license_key_usage.go | 49 +++++++++ models/licenses/license_package.go | 74 +++++++++++++ models/migrations/migrations.go | 1 + models/migrations/v1_27/v335.go | 75 +++++++++++++ routers/web/repo/updateserver.go | 84 +++++++++++++- routers/web/web.go | 3 +- services/updateserver/dolibarr.go | 111 +++++++++++++++++++ services/updateserver/joomla.go | 15 ++- 9 files changed, 568 insertions(+), 3 deletions(-) create mode 100644 models/licenses/license_key.go create mode 100644 models/licenses/license_key_usage.go create mode 100644 models/licenses/license_package.go create mode 100644 models/migrations/v1_27/v335.go create mode 100644 services/updateserver/dolibarr.go diff --git a/models/licenses/license_key.go b/models/licenses/license_key.go new file mode 100644 index 0000000000..500f8fff5f --- /dev/null +++ b/models/licenses/license_key.go @@ -0,0 +1,159 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package licenses + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" + + "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db" + "git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil" +) + +func init() { + db.RegisterModel(new(LicenseKey)) +} + +// LicenseKey represents an individual key issued from a LicensePackage. +type LicenseKey struct { + ID int64 `xorm:"pk autoincr"` + PackageID int64 `xorm:"INDEX NOT NULL"` // FK to license_package + OwnerID int64 `xorm:"INDEX NOT NULL"` // org or user that issued it + KeyHash string `xorm:"UNIQUE NOT NULL"` // SHA-256 of the raw key + KeyPrefix string `xorm:"NOT NULL"` // first 8 chars for display + LicenseeName string `xorm:""` // customer name + LicenseeEmail string `xorm:""` // customer email + DomainRestriction string `xorm:"TEXT"` // comma-separated allowed domains + MaxSites int `xorm:"NOT NULL DEFAULT 0"` // 0 = use package default + IsInternal bool `xorm:"NOT NULL DEFAULT false"` // true = base org/repo key + IsActive bool `xorm:"NOT NULL DEFAULT true"` + StartsUnix timeutil.TimeStamp `xorm:"NOT NULL DEFAULT 0"` // custom start, 0 = creation + ExpiresUnix timeutil.TimeStamp `xorm:"NOT NULL DEFAULT 0"` // 0 = never + CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED"` + UpdatedUnix timeutil.TimeStamp `xorm:"UPDATED"` +} + +func (LicenseKey) TableName() string { + return "license_key" +} + +// GenerateKeyString creates a random license key in MOKO-XXXX-XXXX-XXXX-XXXX format. +func GenerateKeyString() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", err + } + hex := strings.ToUpper(hex.EncodeToString(b)) + return fmt.Sprintf("MOKO-%s-%s-%s-%s", hex[0:4], hex[4:8], hex[8:12], hex[12:16]), nil +} + +// HashKey returns the SHA-256 hash of a raw key string. +func HashKey(rawKey string) string { + h := sha256.Sum256([]byte(rawKey)) + return hex.EncodeToString(h[:]) +} + +// CreateLicenseKey generates a new key, hashes it, stores it, and returns the raw key. +// The raw key is only available at creation time. +func CreateLicenseKey(ctx context.Context, key *LicenseKey) (rawKey string, err error) { + rawKey, err = GenerateKeyString() + if err != nil { + return "", fmt.Errorf("GenerateKeyString: %w", err) + } + + key.KeyHash = HashKey(rawKey) + key.KeyPrefix = rawKey[:12] + "..." + + if _, err := db.GetEngine(ctx).Insert(key); err != nil { + return "", err + } + return rawKey, nil +} + +// GetLicenseKeyByHash looks up a key by its SHA-256 hash. +func GetLicenseKeyByHash(ctx context.Context, hash string) (*LicenseKey, error) { + key := new(LicenseKey) + has, err := db.GetEngine(ctx).Where("key_hash = ?", hash).Get(key) + if err != nil { + return nil, err + } + if !has { + return nil, db.ErrNotExist{Resource: "LicenseKey"} + } + return key, nil +} + +// GetLicenseKeyByID returns a key by its ID. +func GetLicenseKeyByID(ctx context.Context, id int64) (*LicenseKey, error) { + key := new(LicenseKey) + has, err := db.GetEngine(ctx).ID(id).Get(key) + if err != nil { + return nil, err + } + if !has { + return nil, db.ErrNotExist{Resource: "LicenseKey", ID: id} + } + return key, nil +} + +// ListLicenseKeys returns all keys for the given owner. +func ListLicenseKeys(ctx context.Context, ownerID int64) ([]*LicenseKey, error) { + keys := make([]*LicenseKey, 0, 20) + return keys, db.GetEngine(ctx).Where("owner_id = ?", ownerID).Find(&keys) +} + +// ListLicenseKeysByPackage returns all keys for a specific package. +func ListLicenseKeysByPackage(ctx context.Context, packageID int64) ([]*LicenseKey, error) { + keys := make([]*LicenseKey, 0, 20) + return keys, db.GetEngine(ctx).Where("package_id = ?", packageID).Find(&keys) +} + +// UpdateLicenseKey updates a license key. +func UpdateLicenseKey(ctx context.Context, key *LicenseKey) error { + _, err := db.GetEngine(ctx).ID(key.ID).AllCols().Update(key) + return err +} + +// DeleteLicenseKey deletes a license key by ID. +func DeleteLicenseKey(ctx context.Context, id int64) error { + _, err := db.GetEngine(ctx).ID(id).Delete(new(LicenseKey)) + return err +} + +// ValidateLicenseKey validates a raw key string against the database. +// Returns the key record and its associated package, or an error. +func ValidateLicenseKey(ctx context.Context, rawKey string) (*LicenseKey, *LicensePackage, error) { + hash := HashKey(rawKey) + key, err := GetLicenseKeyByHash(ctx, hash) + if err != nil { + return nil, nil, fmt.Errorf("invalid license key") + } + + if !key.IsActive { + return nil, nil, fmt.Errorf("license key is deactivated") + } + + now := timeutil.TimeStampNow() + if key.StartsUnix > 0 && now < key.StartsUnix { + return nil, nil, fmt.Errorf("license key not yet active") + } + if key.ExpiresUnix > 0 && now > key.ExpiresUnix { + return nil, nil, fmt.Errorf("license key has expired") + } + + pkg, err := GetLicensePackageByID(ctx, key.PackageID) + if err != nil { + return nil, nil, fmt.Errorf("license package not found") + } + + if !pkg.IsActive { + return nil, nil, fmt.Errorf("license package is deactivated") + } + + return key, pkg, nil +} diff --git a/models/licenses/license_key_usage.go b/models/licenses/license_key_usage.go new file mode 100644 index 0000000000..57b8acf6e9 --- /dev/null +++ b/models/licenses/license_key_usage.go @@ -0,0 +1,49 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package licenses + +import ( + "context" + + "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db" + "git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil" +) + +func init() { + db.RegisterModel(new(LicenseKeyUsage)) +} + +// LicenseKeyUsage tracks update check activity for a license key. +type LicenseKeyUsage struct { + ID int64 `xorm:"pk autoincr"` + KeyID int64 `xorm:"INDEX NOT NULL"` + RepoID int64 `xorm:"INDEX NOT NULL"` + Domain string `xorm:""` // requesting domain from extra_query + IPAddress string `xorm:""` + UserAgent string `xorm:"TEXT"` + VersionFrom string `xorm:""` // version the client is updating from + CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED"` +} + +func (LicenseKeyUsage) TableName() string { + return "license_key_usage" +} + +// RecordUsage inserts a usage tracking entry. +func RecordUsage(ctx context.Context, usage *LicenseKeyUsage) error { + _, err := db.GetEngine(ctx).Insert(usage) + return err +} + +// GetRecentUsage returns the most recent usage entries for a key. +func GetRecentUsage(ctx context.Context, keyID int64, limit int) ([]*LicenseKeyUsage, error) { + usages := make([]*LicenseKeyUsage, 0, limit) + return usages, db.GetEngine(ctx).Where("key_id = ?", keyID). + OrderBy("created_unix DESC").Limit(limit).Find(&usages) +} + +// CountUsageByKey returns the total number of update checks for a key. +func CountUsageByKey(ctx context.Context, keyID int64) (int64, error) { + return db.GetEngine(ctx).Where("key_id = ?", keyID).Count(new(LicenseKeyUsage)) +} diff --git a/models/licenses/license_package.go b/models/licenses/license_package.go new file mode 100644 index 0000000000..4baeeedcf1 --- /dev/null +++ b/models/licenses/license_package.go @@ -0,0 +1,74 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package licenses + +import ( + "context" + + "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db" + "git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil" +) + +func init() { + db.RegisterModel(new(LicensePackage)) +} + +// LicensePackage defines a purchasable subscription tier that determines +// what update streams a group of license keys can access. +type LicensePackage struct { + ID int64 `xorm:"pk autoincr"` + OwnerID int64 `xorm:"INDEX NOT NULL"` // org or user that owns this package + Name string `xorm:"NOT NULL"` // e.g. "Pro Annual", "Lifetime" + Description string `xorm:"TEXT"` + DurationDays int `xorm:"NOT NULL DEFAULT 0"` // 0 = unlimited/lifetime + MaxSites int `xorm:"NOT NULL DEFAULT 0"` // 0 = unlimited + RepoScope string `xorm:"TEXT NOT NULL DEFAULT 'all'"` // "all" = org-wide, or JSON array of repo IDs + // AllowedChannels defines which update streams keys from this package + // can access. JSON array, e.g. ["stable","rc"]. Empty = all channels. + AllowedChannels string `xorm:"TEXT"` + IsActive bool `xorm:"NOT NULL DEFAULT true"` + CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED"` + UpdatedUnix timeutil.TimeStamp `xorm:"UPDATED"` +} + +func (LicensePackage) TableName() string { + return "license_package" +} + +// CreateLicensePackage creates a new license package. +func CreateLicensePackage(ctx context.Context, pkg *LicensePackage) error { + _, err := db.GetEngine(ctx).Insert(pkg) + return err +} + +// GetLicensePackageByID returns a license package by ID. +func GetLicensePackageByID(ctx context.Context, id int64) (*LicensePackage, error) { + pkg := new(LicensePackage) + has, err := db.GetEngine(ctx).ID(id).Get(pkg) + if err != nil { + return nil, err + } + if !has { + return nil, db.ErrNotExist{Resource: "LicensePackage", ID: id} + } + return pkg, nil +} + +// ListLicensePackages returns all packages for the given owner. +func ListLicensePackages(ctx context.Context, ownerID int64) ([]*LicensePackage, error) { + pkgs := make([]*LicensePackage, 0, 10) + return pkgs, db.GetEngine(ctx).Where("owner_id = ?", ownerID).Find(&pkgs) +} + +// UpdateLicensePackage updates a license package. +func UpdateLicensePackage(ctx context.Context, pkg *LicensePackage) error { + _, err := db.GetEngine(ctx).ID(pkg.ID).AllCols().Update(pkg) + return err +} + +// DeleteLicensePackage deletes a license package by ID. +func DeleteLicensePackage(ctx context.Context, id int64) error { + _, err := db.GetEngine(ctx).ID(id).Delete(new(LicensePackage)) + return err +} diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index bb5774a26f..3ddfb5e980 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -412,6 +412,7 @@ func prepareMigrationTasks() []*migration { newMigration(332, "Add org-level branch protection rulesets", v1_27.AddOrgProtectedBranchTable), newMigration(333, "Add require_2fa to user table for org enforcement", v1_27.AddRequire2FAToUser), newMigration(334, "Add actions user whitelist to protected branches", v1_27.AddActionsUserWhitelistToProtectedBranch), + newMigration(335, "Add license key tables for update server", v1_27.AddLicenseKeyTables), } return preparedMigrations } diff --git a/models/migrations/v1_27/v335.go b/models/migrations/v1_27/v335.go new file mode 100644 index 0000000000..aa707e11bf --- /dev/null +++ b/models/migrations/v1_27/v335.go @@ -0,0 +1,75 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package v1_27 + +import ( + "git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil" + + "xorm.io/xorm" +) + +type licensePackage335 struct { + ID int64 `xorm:"pk autoincr"` + OwnerID int64 `xorm:"INDEX NOT NULL"` + Name string `xorm:"NOT NULL"` + Description string `xorm:"TEXT"` + DurationDays int `xorm:"NOT NULL DEFAULT 0"` + MaxSites int `xorm:"NOT NULL DEFAULT 0"` + RepoScope string `xorm:"TEXT NOT NULL DEFAULT 'all'"` + AllowedChannels string `xorm:"TEXT"` + IsActive bool `xorm:"NOT NULL DEFAULT true"` + CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED"` + UpdatedUnix timeutil.TimeStamp `xorm:"UPDATED"` +} + +func (licensePackage335) TableName() string { + return "license_package" +} + +type licenseKey335 struct { + ID int64 `xorm:"pk autoincr"` + PackageID int64 `xorm:"INDEX NOT NULL"` + OwnerID int64 `xorm:"INDEX NOT NULL"` + KeyHash string `xorm:"UNIQUE NOT NULL"` + KeyPrefix string `xorm:"NOT NULL"` + LicenseeName string `xorm:""` + LicenseeEmail string `xorm:""` + DomainRestriction string `xorm:"TEXT"` + MaxSites int `xorm:"NOT NULL DEFAULT 0"` + IsInternal bool `xorm:"NOT NULL DEFAULT false"` + IsActive bool `xorm:"NOT NULL DEFAULT true"` + StartsUnix timeutil.TimeStamp `xorm:"NOT NULL DEFAULT 0"` + ExpiresUnix timeutil.TimeStamp `xorm:"NOT NULL DEFAULT 0"` + CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED"` + UpdatedUnix timeutil.TimeStamp `xorm:"UPDATED"` +} + +func (licenseKey335) TableName() string { + return "license_key" +} + +type licenseKeyUsage335 struct { + ID int64 `xorm:"pk autoincr"` + KeyID int64 `xorm:"INDEX NOT NULL"` + RepoID int64 `xorm:"INDEX NOT NULL"` + Domain string `xorm:""` + IPAddress string `xorm:""` + UserAgent string `xorm:"TEXT"` + VersionFrom string `xorm:""` + CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED"` +} + +func (licenseKeyUsage335) TableName() string { + return "license_key_usage" +} + +// AddLicenseKeyTables creates the license_package, license_key, and +// license_key_usage tables for the update server license system. +func AddLicenseKeyTables(x *xorm.Engine) error { + return x.Sync( + new(licensePackage335), + new(licenseKey335), + new(licenseKeyUsage335), + ) +} diff --git a/routers/web/repo/updateserver.go b/routers/web/repo/updateserver.go index d26ca922b6..c3c040bcbf 100644 --- a/routers/web/repo/updateserver.go +++ b/routers/web/repo/updateserver.go @@ -5,15 +5,77 @@ package repo import ( "net/http" + "strings" + "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses" + "git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/json" + "git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log" "git.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context" "git.mokoconsulting.tech/MokoConsulting/MokoGitea/services/updateserver" ) +// validateUpdateKey checks for a license key in the request and validates it. +// Returns allowed channels (nil = all channels) and whether access is granted. +func validateUpdateKey(ctx *context.Context) (allowedChannels []string, ok bool) { + rawKey := ctx.FormString("key") + if rawKey == "" { + rawKey = ctx.FormString("download_key") + } + + if rawKey == "" { + // No key provided — allow public access (all channels). + return nil, true + } + + key, pkg, err := licenses.ValidateLicenseKey(ctx, rawKey) + if err != nil { + log.Debug("License key validation failed: %v", err) + return nil, false + } + + // Record usage. + _ = licenses.RecordUsage(ctx, &licenses.LicenseKeyUsage{ + KeyID: key.ID, + RepoID: ctx.Repo.Repository.ID, + Domain: ctx.FormString("domain"), + IPAddress: ctx.RemoteAddr(), + UserAgent: ctx.Req.UserAgent(), + VersionFrom: ctx.FormString("version"), + }) + + // Parse allowed channels from the package. + if pkg.AllowedChannels != "" { + channels := strings.Split(pkg.AllowedChannels, ",") + for i := range channels { + channels[i] = strings.TrimSpace(channels[i]) + } + // Also try JSON array format. + if strings.HasPrefix(pkg.AllowedChannels, "[") { + var parsed []string + if err := json.Unmarshal([]byte(pkg.AllowedChannels), &parsed); err == nil { + channels = parsed + } + } + return channels, true + } + + // Master/internal keys or packages with no channel restriction — all channels. + return nil, true +} + // 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) + allowedChannels, ok := validateUpdateKey(ctx) + if !ok { + // Return empty updates XML for invalid keys (Joomla-compatible). + ctx.Resp.Header().Set("Content-Type", "application/xml; charset=utf-8") + ctx.Resp.WriteHeader(http.StatusOK) + _, _ = ctx.Resp.Write([]byte(``)) + return + } + + xmlData, err := updateserver.GenerateJoomlaXML(ctx, ctx.Repo.Repository, allowedChannels...) if err != nil { ctx.ServerError("GenerateJoomlaXML", err) return @@ -23,3 +85,23 @@ func ServeUpdatesXML(ctx *context.Context) { ctx.Resp.WriteHeader(http.StatusOK) _, _ = ctx.Resp.Write(xmlData) } + +// ServeDolibarrJSON generates and serves a Dolibarr-compatible update feed +// from the repository's releases. +func ServeDolibarrJSON(ctx *context.Context) { + data, err := updateserver.GenerateDolibarrJSON(ctx, ctx.Repo.Repository) + if err != nil { + ctx.ServerError("GenerateDolibarrJSON", 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 47929531c1..8ecbb5d70d 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1494,9 +1494,10 @@ 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) + // "/{username}/{reponame}": update server endpoints m.Group("/{username}/{reponame}", func() { m.Get("/updates.xml", repo.ServeUpdatesXML) + m.Get("/updates/dolibarr.json", repo.ServeDolibarrJSON) }, optSignIn, context.RepoAssignment) // end "/{username}/{reponame}": update server diff --git a/services/updateserver/dolibarr.go b/services/updateserver/dolibarr.go new file mode 100644 index 0000000000..dc4c12d672 --- /dev/null +++ b/services/updateserver/dolibarr.go @@ -0,0 +1,111 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package updateserver + +import ( + "context" + "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" +) + +// DolibarrUpdate represents a single module update entry in Dolibarr format. +type DolibarrUpdate struct { + Name string `json:"name"` + Version string `json:"version"` + Channel string `json:"channel"` + DownloadURL string `json:"url"` + ChangelogURL string `json:"changelog"` + ReleaseURL string `json:"release_url"` + Requires string `json:"requires,omitempty"` + Date string `json:"date"` + SHA256 string `json:"sha256,omitempty"` +} + +// DolibarrUpdates holds the full update feed response. +type DolibarrUpdates struct { + Module string `json:"module"` + Updates []DolibarrUpdate `json:"updates"` +} + +// GenerateDolibarrJSON builds a Dolibarr-compatible update feed from releases. +func GenerateDolibarrJSON(ctx context.Context, repo *repo_model.Repository) (*DolibarrUpdates, 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) + + result := &DolibarrUpdates{ + Module: repo.Name, + } + + // Track best release 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 + } + } + + for _, ch := range []string{"stable", "rc", "beta", "alpha", "dev"} { + rel, ok := bestByChannel[ch] + if !ok { + continue + } + + if err := rel.LoadAttributes(ctx); err != nil { + continue + } + + 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 + } + } + if downloadURL == "" { + downloadURL = fmt.Sprintf("%s/archive/%s.zip", repoLink, rel.TagName) + } + + version := extractVersion(rel.TagName) + suffix := channelSuffix(ch) + if suffix != "" { + version = version + suffix + } + + result.Updates = append(result.Updates, DolibarrUpdate{ + Name: repo.Name, + Version: version, + Channel: ch, + DownloadURL: downloadURL, + ChangelogURL: fmt.Sprintf("%s/raw/branch/%s/CHANGELOG.md", repoLink, repo.DefaultBranch), + ReleaseURL: fmt.Sprintf("%s/releases/tag/%s", repoLink, rel.TagName), + Date: time.Unix(int64(rel.CreatedUnix), 0).Format("2006-01-02"), + }) + } + + return result, nil +} diff --git a/services/updateserver/joomla.go b/services/updateserver/joomla.go index c5669188da..83a224027d 100644 --- a/services/updateserver/joomla.go +++ b/services/updateserver/joomla.go @@ -86,7 +86,8 @@ func channelFromTag(tagName string, isPrerelease bool) string { // 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) { +// allowedChannels optionally restricts output to specific channels (nil = all). +func GenerateJoomlaXML(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, @@ -122,8 +123,20 @@ func GenerateJoomlaXML(ctx context.Context, repo *repo_model.Repository) ([]byte } } + // Build allowed channel set for filtering. + channelAllowed := make(map[string]bool) + if len(allowedChannels) > 0 { + for _, c := range allowedChannels { + channelAllowed[strings.ToLower(c)] = true + } + } + var updates xmlUpdates for _, ch := range []string{"stable", "rc", "beta", "alpha", "dev"} { + // Skip channels not in the allowed set (when filtering is active). + if len(channelAllowed) > 0 && !channelAllowed[ch] { + continue + } rel, ok := bestByChannel[ch] if !ok { continue -- 2.52.0