feat: DLID licensing system — tables, models, update endpoint #659

Merged
jmiller merged 29 commits from rc into main 2026-06-21 00:18:01 +00:00
15 changed files with 876 additions and 140 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
<name>MokoGitea</name>
<org>MokoConsulting</org>
<description>Moko fork of Gitea - adding project board REST API endpoints and custom enhancements</description>
<version>06.19.00</version>
<version>06.20.00</version>
<version-prefix>v1.26.1+MOKO</version-prefix>
<license spdx="GPL-3.0-or-later">GNU General Public License v3</license>
</identity>
+1 -1
View File
@@ -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"
-90
View File
@@ -1,90 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
# 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
-45
View File
@@ -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."
+4 -2
View File
@@ -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
+9 -1
View File
@@ -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
+105
View File
@@ -0,0 +1,105 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// 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"
}
+50
View File
@@ -0,0 +1,50 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// 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)
}
+109
View File
@@ -0,0 +1,109 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// 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
}
+184
View File
@@ -0,0 +1,184 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// 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")
}
+58
View File
@@ -0,0 +1,58 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// 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)
}
+1
View File
@@ -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
}
+108
View File
@@ -0,0 +1,108 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// 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
}
+6
View File
@@ -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
+240
View File
@@ -0,0 +1,240 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// 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 <updates/>
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 + "<updates/>\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
}