fix: Joomla update server — element names, platform gating, domain race #638

Merged
jmiller merged 3 commits from fix into dev 2026-06-18 15:06:07 +00:00
4 changed files with 114 additions and 79 deletions
+8 -3
View File
@@ -5,6 +5,7 @@ package repo
import (
"context"
"strings"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
@@ -60,10 +61,11 @@ func (RepoManifest) TableName() string {
}
// joomlaTypePrefix maps Joomla extension types to their element name prefixes.
// Plugins have no prefix in Joomla's #__extensions table — the element is the
// lowercased, hyphen-free name, and the folder column determines the plugin group.
var joomlaTypePrefix = map[string]string{
"component": "com_",
"module": "mod_",
"plugin": "plg_",
"package": "pkg_",
"template": "tpl_",
"library": "lib_",
@@ -71,14 +73,17 @@ var joomlaTypePrefix = map[string]string{
}
// AutoElementName returns the auto-constructed Joomla element name (e.g. pkg_mokowaas).
// The name is lowercased and hyphens are removed to produce clean element names
// for the #__extensions.element column (e.g. "MokoSuiteBackup" → "pkg_mokosuitebackup").
func (m *RepoManifest) AutoElementName() string {
if m.Name == "" || m.PackageType == "" {
return ""
}
lower := strings.ToLower(strings.ReplaceAll(m.Name, "-", ""))
if prefix, ok := joomlaTypePrefix[m.PackageType]; ok {
return prefix + m.Name
return prefix + lower
}
return m.Name
return lower
}
// FullElementName returns the effective element name: override if set, otherwise auto-constructed.
+76 -68
View File
@@ -256,74 +256,8 @@ func ValidateLicenseKey(ctx context.Context, rawKey, domain string) (*LicenseKey
// Domain restriction check — skip for internal/master keys.
if domain != "" && !key.IsInternal {
now := timeutil.TimeStampNow()
if key.DomainRestriction != "" {
// Domain restriction is set — enforce it.
allowed := false
for _, d := range strings.Split(key.DomainRestriction, ",") {
if strings.EqualFold(strings.TrimSpace(d), domain) {
allowed = true
break
}
}
if !allowed {
// Check if still within the domain lock grace period.
lockHours := pkg.DomainLockHours
if lockHours > 0 && key.FirstUsedUnix > 0 {
lockDeadline := key.FirstUsedUnix + timeutil.TimeStamp(int64(lockHours)*3600)
if now < lockDeadline {
// Grace period active — allow and auto-add this domain.
_ = updateDomainRestriction(ctx, key.ID, domain)
key.DomainRestriction = key.DomainRestriction + "," + domain
allowed = true
}
}
if !allowed {
return nil, nil, fmt.Errorf("domain not allowed for this license key")
}
}
} else {
// No domain restriction set — auto-associate domain.
maxSites := key.MaxSites
if maxSites == 0 {
maxSites = pkg.MaxSites
}
domainKnown, _ := IsDomainKnownForKey(ctx, key.ID, domain)
if !domainKnown {
if maxSites > 0 {
uniqueDomains, err := CountUniqueDomainsByKey(ctx, key.ID)
if err != nil {
return nil, nil, fmt.Errorf("failed to count domains: %w", err)
}
if uniqueDomains >= int64(maxSites) {
return nil, nil, fmt.Errorf("site limit reached (%d/%d)", uniqueDomains, maxSites)
}
}
_ = updateDomainRestriction(ctx, key.ID, domain)
if key.DomainRestriction == "" {
key.DomainRestriction = domain
} else {
key.DomainRestriction = key.DomainRestriction + "," + domain
}
}
}
// Site limit check: use key's MaxSites, fall back to package default.
maxSites := key.MaxSites
if maxSites == 0 {
maxSites = pkg.MaxSites
}
if maxSites > 0 {
uniqueDomains, err := CountUniqueDomainsByKey(ctx, key.ID)
if err != nil {
return nil, nil, fmt.Errorf("failed to count domains: %w", err)
}
// Allow if this domain is already recorded, or if under the limit.
domainKnown, _ := IsDomainKnownForKey(ctx, key.ID, domain)
if !domainKnown && uniqueDomains >= int64(maxSites) {
return nil, nil, fmt.Errorf("site limit reached (%d/%d)", uniqueDomains, maxSites)
}
if err := validateAndAssociateDomain(ctx, key, pkg, domain); err != nil {
return nil, nil, err
}
}
@@ -374,6 +308,80 @@ func ValidateLicenseKeyForRepo(ctx context.Context, rawKey, domain string, repoI
return key, pkg, nil
}
// validateAndAssociateDomain checks domain restrictions and auto-associates new
// domains. The auto-associate path (no existing restriction) runs inside a
// transaction to prevent TOCTOU races on the MaxSites limit. The grace-period
// path (existing restriction, lock window open) also propagates DB errors.
func validateAndAssociateDomain(ctx context.Context, key *LicenseKey, pkg *LicensePackage, domain string) error {
if key.DomainRestriction != "" {
// Domain restriction is set — enforce it.
allowed := false
for _, d := range strings.Split(key.DomainRestriction, ",") {
if strings.EqualFold(strings.TrimSpace(d), domain) {
allowed = true
break
}
}
if !allowed {
// Check if still within the domain lock grace period.
now := timeutil.TimeStampNow()
lockHours := pkg.DomainLockHours
if lockHours > 0 && key.FirstUsedUnix > 0 {
lockDeadline := key.FirstUsedUnix + timeutil.TimeStamp(int64(lockHours)*3600)
if now < lockDeadline {
// Grace period active — allow and auto-add this domain.
if err := updateDomainRestriction(ctx, key.ID, domain); err != nil {
return fmt.Errorf("failed to auto-add domain during grace period: %w", err)
}
key.DomainRestriction = key.DomainRestriction + "," + domain
allowed = true
}
}
if !allowed {
return fmt.Errorf("domain not allowed for this license key")
}
}
return nil
}
// No domain restriction set — auto-associate domain within a transaction
// so the count check and insert are atomic (prevents exceeding MaxSites).
maxSites := key.MaxSites
if maxSites == 0 {
maxSites = pkg.MaxSites
}
return db.WithTx(ctx, func(txCtx context.Context) error {
domainKnown, err := IsDomainKnownForKey(txCtx, key.ID, domain)
if err != nil {
return fmt.Errorf("failed to check domain association: %w", err)
}
if domainKnown {
return nil // already associated, nothing to do
}
if maxSites > 0 {
uniqueDomains, err := CountUniqueDomainsByKey(txCtx, key.ID)
if err != nil {
return fmt.Errorf("failed to count domains: %w", err)
}
if uniqueDomains >= int64(maxSites) {
return fmt.Errorf("site limit reached (%d/%d)", uniqueDomains, maxSites)
}
}
if err := updateDomainRestriction(txCtx, key.ID, domain); err != nil {
return fmt.Errorf("failed to update domain restriction: %w", err)
}
if key.DomainRestriction == "" {
key.DomainRestriction = domain
} else {
key.DomainRestriction = key.DomainRestriction + "," + domain
}
return nil
})
}
// updateDomainRestriction appends a domain to a key's DomainRestriction field in the DB.
func updateDomainRestriction(ctx context.Context, keyID int64, domain string) error {
key, err := GetLicenseKeyByID(ctx, keyID)
+26 -5
View File
@@ -10,8 +10,9 @@ import (
"strings"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
updateserver_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/updateserver"
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
updateserver_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/updateserver"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
)
@@ -57,15 +58,35 @@ func ServeChangelogXML(ctx *context.Context) {
return
}
// Get extension metadata for element name and type.
cfg := updateserver_model.GetEffectiveConfig(ctx, repo.OwnerID, repo.ID)
// Resolve element name and type:
// manifest first, then config table fallback, then repo-derived default.
element := strings.ToLower(repo.Name)
extType := "component"
elementFromManifest := false
extTypeFromManifest := false
manifest, err := repo_model.GetRepoManifest(ctx, repo.ID)
if err != nil {
log.Warn("ServeChangelogXML: GetRepoManifest for repo %d: %v", repo.ID, err)
}
if manifest != nil {
if elem := manifest.FullElementName(); elem != "" {
element = elem
elementFromManifest = true
}
if manifest.PackageType != "" {
extType = manifest.PackageType
extTypeFromManifest = true
}
}
// Config table fallback: apply only when the manifest did not provide a value.
cfg := updateserver_model.GetEffectiveConfig(ctx, repo.OwnerID, repo.ID)
if cfg != nil {
if cfg.ExtensionName != "" {
if !elementFromManifest && cfg.ExtensionName != "" {
element = cfg.ExtensionName
}
if cfg.ExtensionType != "" {
if !extTypeFromManifest && cfg.ExtensionType != "" {
extType = cfg.ExtensionType
}
}
+4 -3
View File
@@ -78,9 +78,10 @@ func validateUpdateKey(ctx *context.Context) (allowedChannels []string, ok bool,
// ServeUpdatesXML generates and serves a Joomla-compatible updates.xml
// from the repository's releases.
func ServeUpdatesXML(ctx *context.Context) {
// Block if platform doesn't include joomla.
platform := ctx.Data["RepoUpdatePlatform"]
if platform == "dolibarr" {
// Block if platform is set to a non-Joomla value.
// Empty/unset defaults to joomla for backwards compatibility.
platform, _ := ctx.Data["RepoUpdatePlatform"].(string)
if platform != "" && platform != "joomla" && platform != "both" {
ctx.NotFound(nil)
return
}