fix: Joomla update server — element names, platform gating, domain race #638
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user