feat: DLID licensing system — tables, models, update endpoint #659
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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."
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user