diff --git a/models/repo/repo_manifest.go b/models/repo/repo_manifest.go index 989342530c..67ed25345c 100644 --- a/models/repo/repo_manifest.go +++ b/models/repo/repo_manifest.go @@ -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. diff --git a/models/updateserver/license_key.go b/models/updateserver/license_key.go index 3eac63278a..a53d934fb8 100644 --- a/models/updateserver/license_key.go +++ b/models/updateserver/license_key.go @@ -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) diff --git a/routers/web/repo/changelog_xml.go b/routers/web/repo/changelog_xml.go index db33a8a26d..b0e83530db 100644 --- a/routers/web/repo/changelog_xml.go +++ b/routers/web/repo/changelog_xml.go @@ -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 } } diff --git a/routers/web/repo/updateserver.go b/routers/web/repo/updateserver.go index 5ff4356a75..afb9f989de 100644 --- a/routers/web/repo/updateserver.go +++ b/routers/web/repo/updateserver.go @@ -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 }