diff --git a/.mokogitea/manifest.xml b/.mokogitea/manifest.xml index 0a9062b5ea..c7e7a8fd95 100644 --- a/.mokogitea/manifest.xml +++ b/.mokogitea/manifest.xml @@ -4,7 +4,7 @@ MokoGitea MokoConsulting Moko fork of Gitea - adding project board REST API endpoints and custom enhancements - 06.19.00 + 06.20.00 v1.26.1+MOKO GNU General Public License v3 diff --git a/.mokogitea/workflows/issue-branch.yml b/.mokogitea/workflows/issue-branch.yml index c2b02a6f91..47cccacebf 100644 --- a/.mokogitea/workflows/issue-branch.yml +++ b/.mokogitea/workflows/issue-branch.yml @@ -5,7 +5,7 @@ # FILE INFORMATION # DEFGROUP: Gitea.Workflow # INGROUP: moko-platform.Automation -# VERSION: 01.00.00 +# VERSION: 06.20.00 # BRIEF: Auto-create feature branch when an issue is opened name: "Universal: Issue Branch" diff --git a/.mokogitea/workflows/pr-branch-check.yml b/.mokogitea/workflows/pr-branch-check.yml deleted file mode 100644 index debdd13b30..0000000000 --- a/.mokogitea/workflows/pr-branch-check.yml +++ /dev/null @@ -1,90 +0,0 @@ -# Copyright (C) 2026 Moko Consulting -# SPDX-License-Identifier: GPL-3.0-or-later -# -# Enforces branch merge policy: -# feature/* → dev only -# fix/* → dev only -# hotfix/* → dev or main (emergency) -# dev → main only -# alpha/* → dev only -# beta/* → dev only -# rc/* → main only - -name: Branch Policy Check - -on: - pull_request: - types: [opened, synchronize, reopened, edited] - -jobs: - check-target: - name: Verify merge target - runs-on: ubuntu-latest - steps: - - name: Check branch policy - run: | - HEAD="${{ github.head_ref }}" - BASE="${{ github.base_ref }}" - - echo "PR: ${HEAD} → ${BASE}" - - ALLOWED=true - REASON="" - - case "$HEAD" in - feature/*|feat/*) - if [ "$BASE" != "dev" ]; then - ALLOWED=false - REASON="Feature branches must target 'dev', not '${BASE}'" - fi - ;; - fix/*|bugfix/*) - if [ "$BASE" != "dev" ]; then - ALLOWED=false - REASON="Fix branches must target 'dev', not '${BASE}'" - fi - ;; - hotfix/*) - if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then - ALLOWED=false - REASON="Hotfix branches can only target 'dev' or 'main', not '${BASE}'" - fi - ;; - alpha/*|beta/*) - if [ "$BASE" != "dev" ]; then - ALLOWED=false - REASON="Pre-release branches must target 'dev', not '${BASE}'" - fi - ;; - rc/*) - if [ "$BASE" != "main" ]; then - ALLOWED=false - REASON="Release candidate branches must target 'main', not '${BASE}'" - fi - ;; - dev) - if [ "$BASE" != "main" ]; then - ALLOWED=false - REASON="Dev branch can only merge into 'main', not '${BASE}'" - fi - ;; - esac - - if [ "$ALLOWED" = false ]; then - echo "::error::${REASON}" - echo "" - echo "## Branch Policy Violation" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "${REASON}" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY - echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY - echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY - echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY - echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY - echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY - exit 1 - fi - - echo "Branch policy: OK (${HEAD} → ${BASE})" - echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY diff --git a/.mokogitea/workflows/pr-check.yml b/.mokogitea/workflows/pr-check.yml index d34108ce5d..409fea5a1f 100644 --- a/.mokogitea/workflows/pr-check.yml +++ b/.mokogitea/workflows/pr-check.yml @@ -487,48 +487,3 @@ jobs: echo "Source: ${FILE_COUNT} files" [ "$FILE_COUNT" -gt 0 ] || { echo "::error::Source directory is empty"; exit 1; } - # ── Pre-Release RC Build ───────────────────────────────────────────────── - pre-release: - name: Build RC Package - runs-on: ubuntu-latest - needs: [branch-policy, validate] - - steps: - - name: Trigger RC pre-release - env: - GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} - REPO: ${{ github.repository }} - BRANCH: ${{ github.head_ref }} - GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} - run: | - curl -s -X POST "${GITEA_URL}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" -d "{\"ref\":\"${BRANCH}\",\"inputs\":{\"stability\":\"release-candidate\"}}" - echo "### Pre-Release" >> $GITHUB_STEP_SUMMARY - echo "Triggered RC build on branch \`${BRANCH}\`" >> $GITHUB_STEP_SUMMARY - - # ── Issue Reporter ────────────────────────────────────────────────────── - report-issues: - name: Report Issues - runs-on: ubuntu-latest - needs: [branch-policy, validate] - if: >- - always() && - needs.validate.result == 'failure' - - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - sparse-checkout: automation/ci-issue-reporter.sh - sparse-checkout-cone-mode: false - - - name: "File issue for PR validation failure" - env: - GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} - GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} - run: | - chmod +x automation/ci-issue-reporter.sh - ./automation/ci-issue-reporter.sh \ - --gate "PR Validation" \ - --workflow "PR Check" \ - --severity error \ - --details "PR validation failed (syntax, manifest, changelog, or source checks). See the CI run for the specific check that failed." diff --git a/.mokogitea/workflows/pre-release.yml b/.mokogitea/workflows/pre-release.yml index b34a311089..5f17b0e3bf 100644 --- a/.mokogitea/workflows/pre-release.yml +++ b/.mokogitea/workflows/pre-release.yml @@ -49,8 +49,10 @@ jobs: name: "Build Pre-Release (${{ inputs.stability || github.ref_name }})" runs-on: release if: >- - github.event_name == 'workflow_dispatch' || - github.event_name == 'push' + (github.event_name == 'workflow_dispatch' || + github.event_name == 'push') && + !contains(github.event.head_commit.message, '[skip ci]') && + !contains(github.event.head_commit.message, '[skip bump]') steps: - name: Checkout diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f70340980..bece1cb5fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,15 @@ # Changelog + ## [Unreleased] -## [06.19.00] --- 2026-06-20 +### Added +- DLID licensing system: license, entitlement, activation, product_tier, audit_log tables (v359 migration) +- License CRUD with CRC32-checksummed DLID generation and format validation +- Entitlement model with tier-based rebuild and custom entitlement preservation +- Domain activation tracking with limit enforcement and auto-activate on first use +- 13 seeded product tiers from base to enterprise +- DLID-gated update XML endpoint: GET /api/v1/licensing/updates/{product}.xml +- Profile repo fallback chain: .mokogitea > .profile > .github ## [06.19.00] --- 2026-06-20 diff --git a/models/licensing/activation.go b/models/licensing/activation.go new file mode 100644 index 0000000000..289204c68b --- /dev/null +++ b/models/licensing/activation.go @@ -0,0 +1,105 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package licensing + +import ( + "context" + + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db" + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil" +) + +func init() { + db.RegisterModel(new(LicenseActivation)) +} + +// LicenseActivation tracks a domain that has activated a license. +type LicenseActivation struct { + ID int64 `xorm:"pk autoincr"` + LicenseID int64 `xorm:"INDEX NOT NULL"` + Domain string `xorm:"VARCHAR(255) NOT NULL"` + IPAddress string `xorm:"VARCHAR(64)"` + JoomlaVer string `xorm:"VARCHAR(20)"` + ActivatedAt timeutil.TimeStamp `xorm:"CREATED"` + LastSeenAt timeutil.TimeStamp +} + +func (LicenseActivation) TableName() string { + return "license_activation" +} + +// GetActivationsByLicense returns all domain activations for a license. +func GetActivationsByLicense(ctx context.Context, licenseID int64) ([]*LicenseActivation, error) { + var acts []*LicenseActivation + return acts, db.GetEngine(ctx).Where("license_id = ?", licenseID).Find(&acts) +} + +// CountActivations returns the number of activated domains for a license. +func CountActivations(ctx context.Context, licenseID int64) (int64, error) { + return db.GetEngine(ctx).Where("license_id = ?", licenseID).Count(new(LicenseActivation)) +} + +// ActivateDomain registers a domain for a license. Returns the activation +// (existing or new) and whether it was newly created. +func ActivateDomain(ctx context.Context, licenseID int64, domain, ip, joomlaVer string, maxDomains int) (*LicenseActivation, bool, error) { + // Check if already activated + existing := new(LicenseActivation) + has, err := db.GetEngine(ctx). + Where("license_id = ? AND domain = ?", licenseID, domain). + Get(existing) + if err != nil { + return nil, false, err + } + if has { + // Update last seen + existing.LastSeenAt = timeutil.TimeStampNow() + existing.IPAddress = ip + if joomlaVer != "" { + existing.JoomlaVer = joomlaVer + } + _, _ = db.GetEngine(ctx).ID(existing.ID).Cols("last_seen_at", "ip_address", "joomla_ver").Update(existing) + return existing, false, nil + } + + // Check domain limit (0 = unlimited) + if maxDomains > 0 { + count, err := CountActivations(ctx, licenseID) + if err != nil { + return nil, false, err + } + if count >= int64(maxDomains) { + return nil, false, ErrDomainLimitReached{LicenseID: licenseID, Max: maxDomains} + } + } + + act := &LicenseActivation{ + LicenseID: licenseID, + Domain: domain, + IPAddress: ip, + JoomlaVer: joomlaVer, + } + _, err = db.GetEngine(ctx).Insert(act) + if err != nil { + return nil, false, err + } + return act, true, nil +} + +// DeactivateDomain removes a domain activation. +func DeactivateDomain(ctx context.Context, licenseID int64, domain string) error { + _, err := db.GetEngine(ctx). + Where("license_id = ? AND domain = ?", licenseID, domain). + Delete(new(LicenseActivation)) + return err +} + +// ErrDomainLimitReached is returned when a license has reached its max activated domains. +type ErrDomainLimitReached struct { + LicenseID int64 + Max int +} + +func (e ErrDomainLimitReached) Error() string { + return "license domain limit reached" +} diff --git a/models/licensing/audit.go b/models/licensing/audit.go new file mode 100644 index 0000000000..cc97f38e8b --- /dev/null +++ b/models/licensing/audit.go @@ -0,0 +1,50 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package licensing + +import ( + "context" + + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db" + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil" +) + +func init() { + db.RegisterModel(new(LicenseAuditLog)) +} + +// LicenseAuditLog records status transitions and other license events. +type LicenseAuditLog struct { + ID int64 `xorm:"pk autoincr"` + LicenseID int64 `xorm:"INDEX NOT NULL"` + Action string `xorm:"VARCHAR(50) NOT NULL"` // status_change, tier_change, domain_activate, domain_deactivate + OldValue string `xorm:"VARCHAR(100)"` + NewValue string `xorm:"VARCHAR(100)"` + CreatedAt timeutil.TimeStamp `xorm:"INDEX CREATED"` +} + +func (LicenseAuditLog) TableName() string { + return "license_audit_log" +} + +// LogLicenseAudit records a license event. +func LogLicenseAudit(ctx context.Context, licenseID int64, action, oldVal, newVal string) error { + entry := &LicenseAuditLog{ + LicenseID: licenseID, + Action: action, + OldValue: oldVal, + NewValue: newVal, + } + _, err := db.GetEngine(ctx).Insert(entry) + return err +} + +// GetAuditLog returns audit entries for a license, newest first. +func GetAuditLog(ctx context.Context, licenseID int64) ([]*LicenseAuditLog, error) { + var entries []*LicenseAuditLog + return entries, db.GetEngine(ctx). + Where("license_id = ?", licenseID). + OrderBy("created_at DESC"). + Find(&entries) +} diff --git a/models/licensing/entitlement.go b/models/licensing/entitlement.go new file mode 100644 index 0000000000..d7c9fb4376 --- /dev/null +++ b/models/licensing/entitlement.go @@ -0,0 +1,109 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package licensing + +import ( + "context" + + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db" + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/json" + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil" +) + +func init() { + db.RegisterModel(new(LicenseEntitlement)) +} + +// LicenseEntitlement maps a license to an individual product (repo) it can access. +type LicenseEntitlement struct { + ID int64 `xorm:"pk autoincr"` + LicenseID int64 `xorm:"INDEX NOT NULL"` + ProductCode string `xorm:"VARCHAR(30) NOT NULL"` + RepoOwner string `xorm:"VARCHAR(100) NOT NULL DEFAULT 'MokoConsulting'"` + RepoName string `xorm:"VARCHAR(100) NOT NULL"` + IsCustom bool `xorm:"NOT NULL DEFAULT false"` // true = manually added, survives tier changes + CreatedAt timeutil.TimeStamp `xorm:"CREATED"` +} + +func (LicenseEntitlement) TableName() string { + return "license_entitlement" +} + +// GetEntitlementsByLicense returns all entitlements for a license. +func GetEntitlementsByLicense(ctx context.Context, licenseID int64) ([]*LicenseEntitlement, error) { + var ents []*LicenseEntitlement + return ents, db.GetEngine(ctx).Where("license_id = ?", licenseID).Find(&ents) +} + +// HasEntitlement checks if a license has access to a specific product code. +func HasEntitlement(ctx context.Context, licenseID int64, productCode string) (bool, error) { + return db.GetEngine(ctx). + Where("license_id = ? AND product_code = ?", licenseID, productCode). + Exist(new(LicenseEntitlement)) +} + +// AddCustomEntitlement adds a manual entitlement that survives tier changes. +func AddCustomEntitlement(ctx context.Context, licenseID int64, productCode, repoName string) error { + ent := &LicenseEntitlement{ + LicenseID: licenseID, + ProductCode: productCode, + RepoOwner: "MokoConsulting", + RepoName: repoName, + IsCustom: true, + } + _, err := db.GetEngine(ctx).Insert(ent) + return err +} + +// RebuildEntitlements deletes non-custom entitlements and rebuilds from the product tier. +// Custom entitlements (manually added) are preserved. +func RebuildEntitlements(ctx context.Context, licenseID int64, tierKey string) error { + // Delete non-custom entitlements + _, err := db.GetEngine(ctx). + Where("license_id = ? AND is_custom = ?", licenseID, false). + Delete(new(LicenseEntitlement)) + if err != nil { + return err + } + + // Look up tier + tier, err := GetProductTierByKey(ctx, tierKey) + if err != nil || tier == nil { + return err + } + + // Parse repos JSON + var repos []string + if err := json.Unmarshal([]byte(tier.Repos), &repos); err != nil { + return err + } + + // Build product code from repo name (lowercase, stripped) + for _, repoName := range repos { + productCode := repoName + // Check if this entitlement already exists (custom) + exists, err := db.GetEngine(ctx). + Where("license_id = ? AND product_code = ?", licenseID, productCode). + Exist(new(LicenseEntitlement)) + if err != nil { + return err + } + if exists { + continue + } + + ent := &LicenseEntitlement{ + LicenseID: licenseID, + ProductCode: productCode, + RepoOwner: "MokoConsulting", + RepoName: repoName, + IsCustom: false, + } + if _, err := db.GetEngine(ctx).Insert(ent); err != nil { + return err + } + } + + return nil +} diff --git a/models/licensing/license.go b/models/licensing/license.go new file mode 100644 index 0000000000..c5daabf204 --- /dev/null +++ b/models/licensing/license.go @@ -0,0 +1,184 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package licensing + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "hash/crc32" + "strings" + "time" + + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db" + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil" +) + +func init() { + db.RegisterModel(new(License)) +} + +// License represents a consumer-facing license with a DLID (Download ID). +type License struct { + ID int64 `xorm:"pk autoincr"` + UserID int64 `xorm:"INDEX NOT NULL"` + DLID string `xorm:"VARCHAR(36) UNIQUE NOT NULL"` + Tier string `xorm:"VARCHAR(30) NOT NULL DEFAULT 'base'"` + MaxDomains int `xorm:"NOT NULL DEFAULT 1"` + Status string `xorm:"VARCHAR(20) NOT NULL DEFAULT 'active'"` // active, expired, revoked, suspended + ExpiresAt timeutil.TimeStamp `xorm:"INDEX"` + Notes string `xorm:"TEXT"` + CreatedAt timeutil.TimeStamp `xorm:"INDEX CREATED"` + UpdatedAt timeutil.TimeStamp `xorm:"UPDATED"` +} + +func (License) TableName() string { + return "license" +} + +// IsExpired returns true if the license has a set expiry that has passed. +func (l *License) IsExpired() bool { + if l.ExpiresAt == 0 { + return false + } + return time.Unix(int64(l.ExpiresAt), 0).Before(time.Now()) +} + +// IsActive returns true if the license status is "active" and not expired. +func (l *License) IsActive() bool { + return l.Status == "active" && !l.IsExpired() +} + +// GenerateDLID creates a new DLID: 28 random hex chars + 4 CRC32 checksum chars, +// formatted as 8-8-8-8 groups. +func GenerateDLID() (string, error) { + b := make([]byte, 14) // 14 bytes = 28 hex chars + if _, err := rand.Read(b); err != nil { + return "", err + } + prefix := hex.EncodeToString(b) + checksum := crc32.ChecksumIEEE([]byte(prefix)) + full := fmt.Sprintf("%s%04x", prefix, checksum&0xFFFF) + // Format as 8-8-8-8 + return fmt.Sprintf("%s-%s-%s-%s", full[0:8], full[8:16], full[16:24], full[24:32]), nil +} + +// ValidateDLIDFormat checks if a DLID has valid format and CRC32 checksum. +// This is a client-side check that catches typos without a database hit. +func ValidateDLIDFormat(dlid string) bool { + clean := strings.ReplaceAll(dlid, "-", "") + if len(clean) != 32 { + return false + } + // Validate hex + if _, err := hex.DecodeString(clean); err != nil { + return false + } + // CRC32 check: last 4 chars should match CRC32 of first 28 + prefix := clean[:28] + expected := fmt.Sprintf("%04x", crc32.ChecksumIEEE([]byte(prefix))&0xFFFF) + return clean[28:32] == expected +} + +// CreateLicense creates a new license with an auto-generated DLID. +func CreateLicense(ctx context.Context, userID int64, tier string, maxDomains int, expiresAt timeutil.TimeStamp) (*License, error) { + dlid, err := GenerateDLID() + if err != nil { + return nil, err + } + + license := &License{ + UserID: userID, + DLID: dlid, + Tier: tier, + MaxDomains: maxDomains, + Status: "active", + ExpiresAt: expiresAt, + } + + _, err = db.GetEngine(ctx).Insert(license) + if err != nil { + return nil, err + } + return license, nil +} + +// GetLicenseByDLID looks up a license by its DLID string. +func GetLicenseByDLID(ctx context.Context, dlid string) (*License, error) { + license := new(License) + has, err := db.GetEngine(ctx).Where("dlid = ?", dlid).Get(license) + if err != nil { + return nil, err + } + if !has { + return nil, nil + } + return license, nil +} + +// GetLicenseByID returns a license by primary key. +func GetLicenseByID(ctx context.Context, id int64) (*License, error) { + license := new(License) + has, err := db.GetEngine(ctx).ID(id).Get(license) + if err != nil { + return nil, err + } + if !has { + return nil, nil + } + return license, nil +} + +// GetLicensesByUser returns all licenses for a user. +func GetLicensesByUser(ctx context.Context, userID int64) ([]*License, error) { + var licenses []*License + return licenses, db.GetEngine(ctx).Where("user_id = ?", userID).Find(&licenses) +} + +// UpdateLicenseTier changes a license's tier, rebuilds entitlements, and logs the change. +func UpdateLicenseTier(ctx context.Context, licenseID int64, newTier string) error { + license, err := GetLicenseByID(ctx, licenseID) + if err != nil || license == nil { + return err + } + oldTier := license.Tier + _, err = db.GetEngine(ctx).ID(licenseID).Cols("tier", "updated_at").Update(&License{Tier: newTier}) + if err != nil { + return err + } + if err := LogLicenseAudit(ctx, licenseID, "tier_change", oldTier, newTier); err != nil { + return err + } + return RebuildEntitlements(ctx, licenseID, newTier) +} + +// SetLicenseStatus updates the status field and logs the transition. +func SetLicenseStatus(ctx context.Context, licenseID int64, status string) error { + license, err := GetLicenseByID(ctx, licenseID) + if err != nil || license == nil { + return err + } + oldStatus := license.Status + _, err = db.GetEngine(ctx).ID(licenseID).Cols("status", "updated_at").Update(&License{Status: status}) + if err != nil { + return err + } + return LogLicenseAudit(ctx, licenseID, "status_change", oldStatus, status) +} + +// RevokeLicense permanently revokes a license. +func RevokeLicense(ctx context.Context, licenseID int64) error { + return SetLicenseStatus(ctx, licenseID, "revoked") +} + +// SuspendLicense temporarily suspends a license. +func SuspendLicense(ctx context.Context, licenseID int64) error { + return SetLicenseStatus(ctx, licenseID, "suspended") +} + +// ReactivateLicense restores a suspended or expired license to active. +func ReactivateLicense(ctx context.Context, licenseID int64) error { + return SetLicenseStatus(ctx, licenseID, "active") +} diff --git a/models/licensing/product_tier.go b/models/licensing/product_tier.go new file mode 100644 index 0000000000..01428e05e5 --- /dev/null +++ b/models/licensing/product_tier.go @@ -0,0 +1,58 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package licensing + +import ( + "context" + + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db" + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/json" +) + +func init() { + db.RegisterModel(new(ProductTier)) +} + +// ProductTier defines a licensing tier and its entitled repositories. +type ProductTier struct { + ID int64 `xorm:"pk autoincr"` + TierKey string `xorm:"VARCHAR(30) UNIQUE NOT NULL"` + TierName string `xorm:"VARCHAR(100) NOT NULL"` + Repos string `xorm:"TEXT"` // JSON array of repo names + MaxDomains int `xorm:"NOT NULL DEFAULT 1"` + SortOrder int `xorm:"NOT NULL DEFAULT 0"` +} + +func (ProductTier) TableName() string { + return "product_tier" +} + +// RepoList parses the Repos JSON field into a string slice. +func (t *ProductTier) RepoList() []string { + var repos []string + if t.Repos == "" { + return repos + } + _ = json.Unmarshal([]byte(t.Repos), &repos) + return repos +} + +// GetProductTierByKey looks up a tier by its key (e.g. "pos", "suite"). +func GetProductTierByKey(ctx context.Context, key string) (*ProductTier, error) { + tier := new(ProductTier) + has, err := db.GetEngine(ctx).Where("tier_key = ?", key).Get(tier) + if err != nil { + return nil, err + } + if !has { + return nil, nil + } + return tier, nil +} + +// GetAllProductTiers returns all tiers ordered by sort_order. +func GetAllProductTiers(ctx context.Context) ([]*ProductTier, error) { + var tiers []*ProductTier + return tiers, db.GetEngine(ctx).OrderBy("sort_order ASC").Find(&tiers) +} diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 2437a2faad..fc5ac382d9 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -435,6 +435,7 @@ func prepareMigrationTasks() []*migration { newMigration(355, "Migrate update server metadata to repo manifest", v1_27.MigrateUpdateServerFieldsToManifest), newMigration(356, "Rename package_type to extension_type in repo manifest", v1_27.RenamePackageTypeToExtensionType), newMigration(357, "Drop display_name from repo manifest and update stream config", v1_27.DropDisplayNameColumns), + newMigration(358, "Add licensing tables (license, entitlement, activation, product_tier)", v1_27.AddLicensingTables), } return preparedMigrations } diff --git a/models/migrations/v1_27/v359.go b/models/migrations/v1_27/v359.go new file mode 100644 index 0000000000..143f8e2e7b --- /dev/null +++ b/models/migrations/v1_27/v359.go @@ -0,0 +1,108 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package v1_27 + +import ( + "xorm.io/xorm" +) + +// AddLicensingTables creates the license, license_entitlement, license_activation, +// and product_tier tables for the consumer-facing DLID licensing system (#617). +func AddLicensingTables(x *xorm.Engine) error { + type License struct { + ID int64 `xorm:"pk autoincr"` + UserID int64 `xorm:"INDEX NOT NULL"` + DLID string `xorm:"VARCHAR(36) UNIQUE NOT NULL"` + Tier string `xorm:"VARCHAR(30) NOT NULL DEFAULT 'base'"` + MaxDomains int `xorm:"NOT NULL DEFAULT 1"` + Status string `xorm:"VARCHAR(20) NOT NULL DEFAULT 'active'"` + ExpiresAt int64 `xorm:"INDEX"` + Notes string `xorm:"TEXT"` + CreatedAt int64 `xorm:"INDEX CREATED"` + UpdatedAt int64 `xorm:"UPDATED"` + } + + type LicenseEntitlement struct { + ID int64 `xorm:"pk autoincr"` + LicenseID int64 `xorm:"INDEX NOT NULL"` + ProductCode string `xorm:"VARCHAR(30) NOT NULL"` + RepoOwner string `xorm:"VARCHAR(100) NOT NULL DEFAULT 'MokoConsulting'"` + RepoName string `xorm:"VARCHAR(100) NOT NULL"` + IsCustom bool `xorm:"NOT NULL DEFAULT false"` + CreatedAt int64 `xorm:"CREATED"` + } + + type LicenseActivation struct { + ID int64 `xorm:"pk autoincr"` + LicenseID int64 `xorm:"INDEX NOT NULL"` + Domain string `xorm:"VARCHAR(255) NOT NULL"` + IPAddress string `xorm:"VARCHAR(64)"` + JoomlaVer string `xorm:"VARCHAR(20)"` + ActivatedAt int64 `xorm:"CREATED"` + LastSeenAt int64 + } + + type ProductTier struct { + ID int64 `xorm:"pk autoincr"` + TierKey string `xorm:"VARCHAR(30) UNIQUE NOT NULL"` + TierName string `xorm:"VARCHAR(100) NOT NULL"` + Repos string `xorm:"TEXT"` + MaxDomains int `xorm:"NOT NULL DEFAULT 1"` + SortOrder int `xorm:"NOT NULL DEFAULT 0"` + } + + type LicenseAuditLog struct { + ID int64 `xorm:"pk autoincr"` + LicenseID int64 `xorm:"INDEX NOT NULL"` + Action string `xorm:"VARCHAR(50) NOT NULL"` + OldValue string `xorm:"VARCHAR(100)"` + NewValue string `xorm:"VARCHAR(100)"` + CreatedAt int64 `xorm:"INDEX CREATED"` + } + + if err := x.Sync(new(License), new(LicenseEntitlement), new(LicenseActivation), new(ProductTier), new(LicenseAuditLog)); err != nil { + return err + } + + // Add composite unique indexes + if _, err := x.Exec("CREATE UNIQUE INDEX IF NOT EXISTS UQE_license_entitlement_lic_prod ON license_entitlement (license_id, product_code)"); err != nil { + // MySQL doesn't support IF NOT EXISTS for indexes — try without + x.Exec("CREATE UNIQUE INDEX UQE_license_entitlement_lic_prod ON license_entitlement (license_id, product_code)") + } + if _, err := x.Exec("CREATE UNIQUE INDEX IF NOT EXISTS UQE_license_activation_lic_domain ON license_activation (license_id, domain)"); err != nil { + x.Exec("CREATE UNIQUE INDEX UQE_license_activation_lic_domain ON license_activation (license_id, domain)") + } + + // Seed product tiers + tiers := []ProductTier{ + {TierKey: "base", TierName: "MokoSuite Base", Repos: `["MokoSuite"]`, MaxDomains: 1, SortOrder: 0}, + {TierKey: "crm", TierName: "MokoSuite CRM", Repos: `["MokoSuite","MokoSuiteCRM"]`, MaxDomains: 3, SortOrder: 10}, + {TierKey: "erp", TierName: "MokoSuite ERP", Repos: `["MokoSuite","MokoSuiteCRM","MokoSuiteERP"]`, MaxDomains: 3, SortOrder: 20}, + {TierKey: "child", TierName: "MokoSuite Child", Repos: `["MokoSuite","MokoSuiteCRM","MokoSuiteChild"]`, MaxDomains: 3, SortOrder: 25}, + {TierKey: "create", TierName: "MokoSuite Create", Repos: `["MokoSuite","MokoSuiteCRM","MokoSuiteCreate"]`, MaxDomains: 3, SortOrder: 26}, + {TierKey: "npo", TierName: "MokoSuite NPO", Repos: `["MokoSuite","MokoSuiteCRM","MokoSuiteNPO"]`, MaxDomains: 3, SortOrder: 27}, + {TierKey: "hrm", TierName: "MokoSuite HRM", Repos: `["MokoSuite","MokoSuiteCRM","MokoSuiteHRM"]`, MaxDomains: 3, SortOrder: 30}, + {TierKey: "mrp", TierName: "MokoSuite MRP", Repos: `["MokoSuite","MokoSuiteCRM","MokoSuiteERP","MokoSuiteMRP"]`, MaxDomains: 3, SortOrder: 35}, + {TierKey: "pos", TierName: "MokoSuite POS", Repos: `["MokoSuite","MokoSuiteCRM","MokoSuiteERP","MokoSuitePOS"]`, MaxDomains: 5, SortOrder: 40}, + {TierKey: "shop", TierName: "MokoSuite Shop", Repos: `["MokoSuite","MokoSuiteCRM","MokoSuiteERP","MokoSuiteShop"]`, MaxDomains: 5, SortOrder: 45}, + {TierKey: "restaurant", TierName: "MokoSuite Restaurant", Repos: `["MokoSuite","MokoSuiteCRM","MokoSuiteERP","MokoSuitePOS","MokoSuiteRestaurant"]`, MaxDomains: 5, SortOrder: 50}, + {TierKey: "suite", TierName: "MokoSuite Suite", Repos: `["MokoSuite","MokoSuiteCRM","MokoSuiteERP","MokoSuitePOS","MokoSuiteShop","MokoSuiteHRM","MokoSuiteMRP","MokoSuiteChild","MokoSuiteCreate","MokoSuiteNPO","MokoSuiteRestaurant","MokoSuiteForms"]`, MaxDomains: 10, SortOrder: 90}, + {TierKey: "enterprise", TierName: "MokoSuite Enterprise", Repos: `["MokoSuite","MokoSuiteCRM","MokoSuiteERP","MokoSuitePOS","MokoSuiteShop","MokoSuiteHRM","MokoSuiteMRP","MokoSuiteChild","MokoSuiteCreate","MokoSuiteNPO","MokoSuiteRestaurant","MokoSuiteForms","MokoSuiteCommunity","MokoSuiteBackup","MokoSuiteStoreLocator","MokoSuiteOpenGraph","MokoSuiteCross"]`, MaxDomains: 0, SortOrder: 100}, + } + + for _, t := range tiers { + // Only insert if the tier doesn't already exist + count, err := x.Where("tier_key = ?", t.TierKey).Count(new(ProductTier)) + if err != nil { + return err + } + if count == 0 { + if _, err := x.Insert(&t); err != nil { + return err + } + } + } + + return nil +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index d810b4ddeb..16605b5558 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -82,6 +82,7 @@ import ( "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/web" "code.mokoconsulting.tech/MokoConsulting/MokoGitea/routers/api/v1/activitypub" "code.mokoconsulting.tech/MokoConsulting/MokoGitea/routers/api/v1/admin" + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/routers/api/v1/licensing" "code.mokoconsulting.tech/MokoConsulting/MokoGitea/routers/api/v1/misc" "code.mokoconsulting.tech/MokoConsulting/MokoGitea/routers/api/v1/notify" "code.mokoconsulting.tech/MokoConsulting/MokoGitea/routers/api/v1/org" @@ -1858,6 +1859,11 @@ func Routes() *web.Router { m.Group("/topics", func() { m.Get("/search", repo.TopicSearch) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository)) + + // Licensing endpoints — DLID-gated, no token required + m.Group("/licensing", func() { + m.Get("/updates/{product}", licensing.ServeUpdates) + }) }, sudo()) return m diff --git a/routers/api/v1/licensing/updates.go b/routers/api/v1/licensing/updates.go new file mode 100644 index 0000000000..e2568e62f4 --- /dev/null +++ b/routers/api/v1/licensing/updates.go @@ -0,0 +1,240 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package licensing + +import ( + "encoding/xml" + "fmt" + "net/http" + "strings" + + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db" + licensing_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licensing" + repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo" + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log" + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/setting" + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context" +) + +// Joomla update XML structures. + +type xmlUpdates struct { + XMLName xml.Name `xml:"updates"` + Updates []xmlUpdate `xml:"update"` +} + +type xmlUpdate struct { + Name string `xml:"name"` + Element string `xml:"element"` + Type string `xml:"type"` + Version string `xml:"version"` + Tag string `xml:"tag"` + DownloadURL xmlDownload `xml:"downloadurl"` + TargetPlatform xmlTarget `xml:"targetplatform"` + PHPMinimum string `xml:"php_minimum,omitempty"` +} + +type xmlDownload struct { + Type string `xml:"type,attr"` + Format string `xml:"format,attr"` + URL string `xml:",chardata"` +} + +type xmlTarget struct { + Name string `xml:"name,attr"` + Version string `xml:"version,attr"` +} + +// ServeUpdates handles GET /api/v1/licensing/updates/{product}.xml?dlid=XXX&domain=YYY +func ServeUpdates(ctx *context.APIContext) { + productFile := ctx.PathParam("product") + productCode := strings.TrimSuffix(productFile, ".xml") + dlid := ctx.FormString("dlid") + domain := ctx.FormString("domain") + + // Always return XML content type + ctx.Resp.Header().Set("Content-Type", "application/xml; charset=utf-8") + + // Validation failure → empty + if dlid == "" || !licensing_model.ValidateDLIDFormat(dlid) { + writeEmptyUpdates(ctx) + return + } + + // Look up license + license, err := licensing_model.GetLicenseByDLID(ctx, dlid) + if err != nil { + log.Error("ServeUpdates: GetLicenseByDLID: %v", err) + writeEmptyUpdates(ctx) + return + } + if license == nil || !license.IsActive() { + writeEmptyUpdates(ctx) + return + } + + // Check entitlement + hasAccess, err := licensing_model.HasEntitlement(ctx, license.ID, productCode) + if err != nil { + log.Error("ServeUpdates: HasEntitlement: %v", err) + writeEmptyUpdates(ctx) + return + } + if !hasAccess { + writeEmptyUpdates(ctx) + return + } + + // Auto-activate domain + if domain != "" { + ip := ctx.Req.RemoteAddr + if idx := strings.LastIndex(ip, ":"); idx >= 0 { + ip = ip[:idx] + } + _, _, err := licensing_model.ActivateDomain(ctx, license.ID, domain, ip, ctx.FormString("joomla_version"), license.MaxDomains) + if err != nil { + if _, ok := err.(licensing_model.ErrDomainLimitReached); ok { + writeEmptyUpdates(ctx) + return + } + log.Error("ServeUpdates: ActivateDomain: %v", err) + } + } + + // Resolve repo from entitlement + ents, err := licensing_model.GetEntitlementsByLicense(ctx, license.ID) + if err != nil { + log.Error("ServeUpdates: GetEntitlementsByLicense: %v", err) + writeEmptyUpdates(ctx) + return + } + + var repoOwner, repoName string + for _, ent := range ents { + if ent.ProductCode == productCode { + repoOwner = ent.RepoOwner + repoName = ent.RepoName + break + } + } + if repoName == "" { + writeEmptyUpdates(ctx) + return + } + + // Find the repo + repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, repoOwner, repoName) + if err != nil || repo == nil { + log.Error("ServeUpdates: repo %s/%s not found: %v", repoOwner, repoName, err) + writeEmptyUpdates(ctx) + return + } + + // Get stable release + releases, err := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{ + RepoID: repo.ID, + ListOptions: db.ListOptions{PageSize: 1, Page: 1}, + IncludeDrafts: false, + IncludeTags: false, + TagNames: []string{"stable"}, + }) + if err != nil || len(releases) == 0 { + // Try latest non-draft release (default order is newest first) + releases, err = db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{ + RepoID: repo.ID, + ListOptions: db.ListOptions{PageSize: 1, Page: 1}, + IncludeDrafts: false, + IncludeTags: false, + }) + if err != nil || len(releases) == 0 { + writeEmptyUpdates(ctx) + return + } + } + + rel := releases[0] + version := extractVersion(rel.TagName) + if version == "" && rel.Title != "" { + version = extractVersion(rel.Title) + } + if version == "" { + version = rel.TagName + } + + // Get repo metadata for element name, type, etc. + manifest, _ := repo_model.GetRepoMetadata(ctx, repo.ID) + element := strings.ToLower(repoName) + extType := "package" + phpMin := "8.1" + targetVer := "6..*" + displayName := repoName + + if manifest != nil { + if e := manifest.FullElementName(); e != "" { + element = e + } + if manifest.ExtensionType != "" { + extType = manifest.ExtensionType + } + if manifest.PHPMinimum != "" { + phpMin = manifest.PHPMinimum + } + if manifest.TargetVersion != "" { + targetVer = manifest.TargetVersion + } + displayName = manifest.DerivedDisplayName() + } + + // Build download URL + baseURL := setting.AppURL + downloadURL := fmt.Sprintf("%sapi/v1/licensing/download/%s/%s.zip?dlid=%s", + baseURL, productCode, version, dlid) + + updates := xmlUpdates{ + Updates: []xmlUpdate{ + { + Name: displayName, + Element: element, + Type: extType, + Version: version, + Tag: "stable", + DownloadURL: xmlDownload{ + Type: "full", + Format: "zip", + URL: downloadURL, + }, + TargetPlatform: xmlTarget{ + Name: "joomla", + Version: targetVer, + }, + PHPMinimum: phpMin, + }, + }, + } + + output, err := xml.MarshalIndent(updates, "", " ") + if err != nil { + log.Error("ServeUpdates: xml.MarshalIndent: %v", err) + writeEmptyUpdates(ctx) + return + } + + ctx.Resp.WriteHeader(http.StatusOK) + _, _ = ctx.Resp.Write([]byte(xml.Header)) + _, _ = ctx.Resp.Write(output) +} + +func writeEmptyUpdates(ctx *context.APIContext) { + ctx.Resp.WriteHeader(http.StatusOK) + _, _ = ctx.Resp.Write([]byte(xml.Header + "\n")) +} + +// extractVersion strips common tag prefixes to get a clean version. +func extractVersion(s string) string { + v := s + for _, prefix := range []string{"v", "release-", "release/", "stable-"} { + v = strings.TrimPrefix(v, prefix) + } + return v +}