feat(licenses): full commercial license management system v1.26.1-moko.06.02.00 #402

Merged
jmiller merged 15 commits from dev into main 2026-06-02 12:00:24 +00:00
33 changed files with 2507 additions and 142 deletions
+27
View File
@@ -3,6 +3,33 @@
This changelog goes through the changes that have been made in each release
without substantial changes to our git log; to see the highlights of what has
been added to each release, please refer to the [blog](https://blog.gitea.com).
## [v1.26.1-moko.06.02.00] - 2026-06-02
* FEATURES
* feat(licenses): full commercial license management system
* Package archiving with soft-delete and collapsible archived section
* Search keys by customer, domain, key number, email, or payment ref
* Download gating (none/prerelease/all modes)
* Feed visibility (public/no-download/hidden modes)
* Domain lock grace period (DomainLockHours)
* RepoScope enforcement — packages scoped to specific repos
* Joomla changelog XML endpoint (/changelog.xml)
* WordPress PUC-compatible update feed (/updates/wordpress.json)
* SHA256 checksums from sidecar files in Joomla updates.xml
* Joomla-standard tag values (dev/alpha/beta/rc/stable)
* Double confirmation modals for permanent deletion
* Combolist channel picker (replaces checkboxes)
* Extension metadata in repo settings (per-repo override)
* API: package CRUD, key revoke, key renew, settings GET/PUT
* API: purchase webhook with PaymentRef idempotency
* API: public validation endpoint (no auth)
* Migration v340: all new columns synced
* feat(updates): infourl defaults to release listing page
* feat(updates): downloadkey prefix matches Akeeba pattern (dlid=)
* fix(licenses): expanded delete permissions to org owners + site admins
* fix(licenses): no-download mode shows release notes but hides files
* fix(licenses): releases require login in hidden feed visibility mode
## [v1.26.1-moko.05.15.00] - 2026-05-31
* BREAKING CHANGES
+96 -7
View File
@@ -9,9 +9,11 @@ import (
"crypto/sha256"
"encoding/hex"
"fmt"
"strconv"
"strings"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/json"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
)
@@ -37,6 +39,7 @@ type LicenseKey struct {
StartsUnix timeutil.TimeStamp `xorm:"NOT NULL DEFAULT 0"` // custom start, 0 = creation
ExpiresUnix timeutil.TimeStamp `xorm:"NOT NULL DEFAULT 0"` // 0 = never
LastHeartbeatUnix timeutil.TimeStamp `xorm:"NOT NULL DEFAULT 0"` // last successful validation
FirstUsedUnix timeutil.TimeStamp `xorm:"NOT NULL DEFAULT 0"` // first successful validation (for domain lock timer)
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED"`
UpdatedUnix timeutil.TimeStamp `xorm:"UPDATED"`
}
@@ -123,6 +126,17 @@ func ListLicenseKeys(ctx context.Context, ownerID int64) ([]*LicenseKey, error)
return keys, db.GetEngine(ctx).Where("owner_id = ?", ownerID).Find(&keys)
}
// SearchLicenseKeys searches keys for an owner by key prefix/raw, licensee, email, or domain.
func SearchLicenseKeys(ctx context.Context, ownerID int64, query string) ([]*LicenseKey, error) {
keys := make([]*LicenseKey, 0, 20)
like := "%" + strings.ToLower(query) + "%"
return keys, db.GetEngine(ctx).
Where("owner_id = ?", ownerID).
And("(LOWER(key_prefix) LIKE ? OR LOWER(key_raw) LIKE ? OR LOWER(licensee_name) LIKE ? OR LOWER(licensee_email) LIKE ? OR LOWER(domain_restriction) LIKE ? OR LOWER(payment_ref) LIKE ?)",
like, like, like, like, like, like).
Find(&keys)
}
// ListLicenseKeysByPackage returns all keys for a specific package.
func ListLicenseKeysByPackage(ctx context.Context, packageID int64) ([]*LicenseKey, error) {
keys := make([]*LicenseKey, 0, 20)
@@ -171,10 +185,24 @@ func DeleteExpiredKeys(ctx context.Context, olderThanDays int) (int64, error) {
}
// TouchHeartbeat updates the last heartbeat timestamp for a key.
// On first call, also sets FirstUsedUnix.
func TouchHeartbeat(ctx context.Context, keyID int64) error {
_, err := db.GetEngine(ctx).ID(keyID).
Cols("last_heartbeat_unix").
Update(&LicenseKey{LastHeartbeatUnix: timeutil.TimeStampNow()})
now := timeutil.TimeStampNow()
key, err := GetLicenseKeyByID(ctx, keyID)
if err != nil {
return err
}
key.LastHeartbeatUnix = now
if key.FirstUsedUnix == 0 {
key.FirstUsedUnix = now
_, err = db.GetEngine(ctx).ID(keyID).
Cols("last_heartbeat_unix", "first_used_unix").
Update(key)
} else {
_, err = db.GetEngine(ctx).ID(keyID).
Cols("last_heartbeat_unix").
Update(key)
}
return err
}
@@ -212,9 +240,15 @@ func ValidateLicenseKey(ctx context.Context, rawKey, domain string) (*LicenseKey
return nil, nil, fmt.Errorf("license package is deactivated")
}
// RepoScope validation is done by the caller (update server passes repoID).
// See ValidateLicenseKeyForRepo below.
// Domain restriction check — skip for internal/master keys.
if domain != "" && !key.IsInternal {
now := timeutil.TimeStampNow()
if key.DomainRestriction != "" {
// Domain restriction is set — enforce it.
allowed := false
for _, d := range strings.Split(key.DomainRestriction, ",") {
if strings.EqualFold(strings.TrimSpace(d), domain) {
@@ -223,11 +257,23 @@ func ValidateLicenseKey(ctx context.Context, rawKey, domain string) (*LicenseKey
}
}
if !allowed {
return nil, nil, fmt.Errorf("domain not allowed for this license key")
// Check if still within the domain lock grace period.
lockHours := pkg.DomainLockHours
if lockHours > 0 && key.FirstUsedUnix > 0 {
lockDeadline := key.FirstUsedUnix + timeutil.TimeStamp(int64(lockHours)*3600)
if now < lockDeadline {
// Grace period active — allow and auto-add this domain.
_ = updateDomainRestriction(ctx, key.ID, domain)
key.DomainRestriction = key.DomainRestriction + "," + domain
allowed = true
}
}
if !allowed {
return nil, nil, fmt.Errorf("domain not allowed for this license key")
}
}
} else {
// No domain restriction set — auto-associate on first heartbeat.
// Append this domain to the restriction list, enforcing max_sites.
// No domain restriction set — auto-associate domain.
maxSites := key.MaxSites
if maxSites == 0 {
maxSites = pkg.MaxSites
@@ -243,7 +289,6 @@ func ValidateLicenseKey(ctx context.Context, rawKey, domain string) (*LicenseKey
return nil, nil, fmt.Errorf("site limit reached (%d/%d)", uniqueDomains, maxSites)
}
}
// Append this domain to the key's restriction list.
_ = updateDomainRestriction(ctx, key.ID, domain)
if key.DomainRestriction == "" {
key.DomainRestriction = domain
@@ -274,6 +319,50 @@ func ValidateLicenseKey(ctx context.Context, rawKey, domain string) (*LicenseKey
return key, pkg, nil
}
// ValidateLicenseKeyForRepo validates a key and checks that the package's RepoScope
// includes the given repo. This is the primary entry point for update server validation.
func ValidateLicenseKeyForRepo(ctx context.Context, rawKey, domain string, repoID int64) (*LicenseKey, *LicensePackage, error) {
key, pkg, err := ValidateLicenseKey(ctx, rawKey, domain)
if err != nil {
return nil, nil, err
}
// Master/internal keys bypass repo scope.
if key.IsInternal {
return key, pkg, nil
}
// Check repo scope.
if pkg.RepoScope != "" && pkg.RepoScope != "all" {
scopeStr := pkg.RepoScope
allowed := false
if strings.HasPrefix(scopeStr, "[") {
// JSON array format: parse properly to avoid substring matching bugs.
var ids []int64
if err := json.Unmarshal([]byte(scopeStr), &ids); err == nil {
for _, id := range ids {
if id == repoID {
allowed = true
break
}
}
}
} else {
// Single repo ID string.
if id, err := strconv.ParseInt(scopeStr, 10, 64); err == nil {
allowed = id == repoID
}
}
if !allowed {
return nil, nil, fmt.Errorf("license key not valid for this repository")
}
}
return key, pkg, nil
}
// updateDomainRestriction appends a domain to a key's DomainRestriction field in the DB.
func updateDomainRestriction(ctx context.Context, keyID int64, domain string) error {
key, err := GetLicenseKeyByID(ctx, keyID)
+22 -2
View File
@@ -25,11 +25,13 @@ type LicensePackage struct {
Description string `xorm:"TEXT"`
DurationDays int `xorm:"NOT NULL DEFAULT 0"` // 0 = unlimited/lifetime
MaxSites int `xorm:"NOT NULL DEFAULT 0"` // 0 = unlimited
DomainLockHours int `xorm:"NOT NULL DEFAULT 0"` // hours after first use to lock domain. 0 = immediate lock
RepoScope string `xorm:"TEXT NOT NULL DEFAULT 'all'"` // "all" = org-wide, or JSON array of repo IDs
// AllowedChannels defines which update streams keys from this package
// can access. JSON array, e.g. ["stable","rc"]. Empty = all channels.
AllowedChannels string `xorm:"TEXT"`
IsActive bool `xorm:"NOT NULL DEFAULT true"`
IsArchived bool `xorm:"NOT NULL DEFAULT false"`
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED"`
UpdatedUnix timeutil.TimeStamp `xorm:"UPDATED"`
}
@@ -71,10 +73,28 @@ func (opts FindLicensePackageOptions) ToConds() builder.Cond {
return cond
}
// ListLicensePackages returns all packages for the given owner.
// ListLicensePackages returns active (non-archived) packages for the given owner.
func ListLicensePackages(ctx context.Context, ownerID int64) ([]*LicensePackage, error) {
pkgs := make([]*LicensePackage, 0, 10)
return pkgs, db.GetEngine(ctx).Where("owner_id = ?", ownerID).Find(&pkgs)
return pkgs, db.GetEngine(ctx).Where("owner_id = ? AND is_archived = ?", ownerID, false).Find(&pkgs)
}
// ListArchivedLicensePackages returns archived packages for the given owner.
func ListArchivedLicensePackages(ctx context.Context, ownerID int64) ([]*LicensePackage, error) {
pkgs := make([]*LicensePackage, 0, 10)
return pkgs, db.GetEngine(ctx).Where("owner_id = ? AND is_archived = ?", ownerID, true).Find(&pkgs)
}
// ArchiveLicensePackage sets a package as archived.
func ArchiveLicensePackage(ctx context.Context, id int64) error {
_, err := db.GetEngine(ctx).ID(id).Cols("is_archived").Update(&LicensePackage{IsArchived: true})
return err
}
// UnarchiveLicensePackage removes the archived flag from a package.
func UnarchiveLicensePackage(ctx context.Context, id int64) error {
_, err := db.GetEngine(ctx).ID(id).Cols("is_archived").Update(&LicensePackage{IsArchived: false})
return err
}
// UpdateLicensePackage updates a license package.
+3
View File
@@ -27,6 +27,9 @@ type UpdateStreamConfig struct {
Platform string `xorm:"NOT NULL DEFAULT 'joomla'"` // joomla, dolibarr, both, wordpress, prestashop, drupal
LicensingEnabled bool `xorm:"NOT NULL DEFAULT false"` // master toggle for licensing system
RequireKey bool `xorm:"NOT NULL DEFAULT false"` // require license key for update feed
FeedVisibility string `xorm:"VARCHAR(20) NOT NULL DEFAULT 'public'"` // public, no-download, hidden
DownloadGating string `xorm:"VARCHAR(20) NOT NULL DEFAULT 'none'"` // none, all, prerelease
SupportURL string `xorm:"TEXT"` // wiki or external support page URL
// Extension metadata — used in update feed generation.
ExtensionName string `xorm:"TEXT"` // element identifier (e.g. pkg_mokowaas, com_mokowaas)
DisplayName string `xorm:"TEXT"` // human-readable name (e.g. "Package - MokoWaaS")
+2
View File
@@ -416,6 +416,8 @@ func prepareMigrationTasks() []*migration {
newMigration(336, "Add update stream config table", v1_27.AddUpdateStreamConfigTable),
newMigration(337, "Add key_plain column to license_key", v1_27.AddKeyPlainToLicenseKey),
newMigration(338, "Add platform and require_key to update_stream_config", v1_27.AddPlatformAndRequireKeyToStreamConfig),
newMigration(339, "Add AI assistant tables", v1_27.AddAITables),
newMigration(340, "Sync license system columns (key_raw, payment_ref, heartbeat, archive, metadata)", v1_27.SyncLicenseSystemColumns),
}
return preparedMigrations
}
+66
View File
@@ -0,0 +1,66 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package v1_27
import (
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
"xorm.io/xorm"
)
// licenseKey340 adds columns that were introduced after v335/v337.
type licenseKey340 struct {
ID int64 `xorm:"pk autoincr"`
KeyRaw string `xorm:"TEXT"`
PaymentRef string `xorm:"UNIQUE"`
LastHeartbeatUnix timeutil.TimeStamp `xorm:"NOT NULL DEFAULT 0"`
FirstUsedUnix timeutil.TimeStamp `xorm:"NOT NULL DEFAULT 0"`
}
func (licenseKey340) TableName() string {
return "license_key"
}
// licensePackage340 adds the IsArchived and DomainLockHours columns.
type licensePackage340 struct {
ID int64 `xorm:"pk autoincr"`
IsArchived bool `xorm:"NOT NULL DEFAULT false"`
DomainLockHours int `xorm:"NOT NULL DEFAULT 0"`
}
func (licensePackage340) TableName() string {
return "license_package"
}
// updateStreamConfig340 adds licensing, gating, and extension metadata columns.
type updateStreamConfig340 struct {
ID int64 `xorm:"pk autoincr"`
LicensingEnabled bool `xorm:"NOT NULL DEFAULT false"`
FeedVisibility string `xorm:"VARCHAR(20) NOT NULL DEFAULT 'public'"`
DownloadGating string `xorm:"VARCHAR(20) NOT NULL DEFAULT 'none'"`
SupportURL string `xorm:"TEXT"`
ExtensionName string `xorm:"TEXT"`
DisplayName string `xorm:"TEXT"`
Description string `xorm:"TEXT"`
ExtensionType string `xorm:"VARCHAR(50)"`
Maintainer string `xorm:"TEXT"`
MaintainerURL string `xorm:"TEXT"`
InfoURL string `xorm:"TEXT"`
TargetVersion string `xorm:"TEXT"`
PHPMinimum string `xorm:"VARCHAR(20)"`
}
func (updateStreamConfig340) TableName() string {
return "update_stream_config"
}
// SyncLicenseSystemColumns adds missing columns to license_key,
// license_package, and update_stream_config tables.
func SyncLicenseSystemColumns(x *xorm.Engine) error {
return x.Sync(
new(licenseKey340),
new(licensePackage340),
new(updateStreamConfig340),
)
}
+17
View File
@@ -121,6 +121,23 @@ type ValidateLicenseKeyResponse struct {
MaxSites int `json:"max_sites"`
}
// LicenseSettings represents the licensing/update stream configuration for a repo.
type LicenseSettings struct {
LicensingEnabled bool `json:"licensing_enabled"`
RequireKey bool `json:"require_key"`
DownloadGating string `json:"download_gating"`
Platform string `json:"platform"`
SupportURL string `json:"support_url"`
ExtensionName string `json:"extension_name,omitempty"`
DisplayName string `json:"display_name,omitempty"`
ExtensionType string `json:"extension_type,omitempty"`
Maintainer string `json:"maintainer,omitempty"`
MaintainerURL string `json:"maintainer_url,omitempty"`
InfoURL string `json:"info_url,omitempty"`
TargetVersion string `json:"target_version,omitempty"`
PHPMinimum string `json:"php_minimum,omitempty"`
}
// LicenseKeyUsage represents a usage tracking entry.
type LicenseKeyUsage struct {
ID int64 `json:"id"`
+41
View File
@@ -2676,6 +2676,8 @@
"repo.licenses.feed_dolibarr_json": "Dolibarr JSON",
"repo.licenses.feed_joomla_updates": "Joomla updates.xml",
"repo.licenses.feed_dolibarr_updates": "Dolibarr JSON",
"repo.licenses.feed_wordpress_updates": "WordPress (PUC JSON)",
"repo.licenses.feed_changelog_xml": "Changelog XML (Joomla)",
"repo.licenses.master_label": "Master",
"repo.licenses.unlimited": "unlimited",
"repo.licenses.active_help_package": "Deactivating blocks new key creation and disables all issued keys.",
@@ -2689,6 +2691,33 @@
"repo.licenses.delete_key": "Delete Key",
"repo.licenses.confirm_delete_key": "Permanently delete this license key? This cannot be undone.",
"repo.licenses.key_deleted": "License key deleted.",
"repo.licenses.archived_packages": "Archived Packages",
"repo.licenses.archive_package": "Archive Package",
"repo.licenses.unarchive_package": "Restore Package",
"repo.licenses.confirm_archive_package": "Archive this package? It will be hidden from the main list but existing keys will continue working.",
"repo.licenses.package_archived": "License package archived.",
"repo.licenses.package_unarchived": "License package restored.",
"repo.licenses.domain": "Domain",
"repo.licenses.any_domain": "any",
"repo.licenses.search_placeholder": "Search by customer, domain, key, or email\u2026",
"repo.licenses.clear_search": "Clear",
"repo.licenses.search_results": "Found %d key(s) matching \u201c%s\u201d",
"repo.licenses.no_search_results": "No keys match your search.",
"repo.licenses.domain_lock_hours": "Domain Lock Grace Period (hours)",
"repo.licenses.domain_lock_hours_help": "Hours after first use before the key locks to its domain(s). 0 = lock immediately on first use.",
"repo.licenses.repo_scope": "Repository Scope",
"repo.licenses.repo_scope_all": "All repositories in this organization",
"repo.licenses.repo_scope_help": "Which repositories this package's keys can access. Select a specific repo or allow all.",
"repo.licenses.confirm_delete_package_typed": "This will permanently delete the package and may orphan associated keys. This action cannot be undone.",
"repo.licenses.confirm_delete_key_typed": "This will permanently remove the license key record. The licensee will immediately lose access.",
"repo.licenses.type_name_to_confirm": "Type the package name to confirm:",
"repo.settings.download_gating": "Download Gating",
"repo.settings.support_url": "Support / Product Page URL",
"repo.settings.support_url_help": "Shown when downloads are gated. Can point to your wiki, product page, or external support site.",
"repo.release.downloads_require_login": "Sign in to download release files.",
"repo.settings.extension_metadata": "Extension Metadata",
"repo.settings.extension_metadata_desc": "Override the org-level extension metadata for this repository. Empty fields inherit from the organization settings.",
"repo.settings.inherit_org": "(inherit from org)",
"repo.release.draft": "Draft",
"repo.release.prerelease": "Pre-Release",
"repo.release.stable": "Stable",
@@ -2837,6 +2866,18 @@
"org.settings.enable_licensing_help": "Show the Licenses page in the org menu and enable license key management. Individual repos can also enable licensing independently.",
"org.settings.require_key": "Require license key for all update feeds",
"org.settings.require_key_help": "Update feeds return empty results unless a valid key is provided. Joomla clients will see a Download Key field. Individual repos can override this.",
"org.settings.feed_visibility": "Update Feed Visibility",
"org.settings.feed_visibility_public": "Public — full feed with download URLs visible to everyone",
"org.settings.feed_visibility_no_download": "Show versions only — version info visible but download URLs hidden without a key",
"org.settings.feed_visibility_hidden": "Hidden — empty feed returned without a valid license key",
"org.settings.feed_visibility_help": "Controls what unauthenticated users see when they access the update feed URL.",
"org.settings.download_gating": "Download Gating",
"org.settings.download_gating_none": "None — all downloads are public",
"org.settings.download_gating_prerelease": "Pre-release only — stable downloads are public, pre-release builds require a key",
"org.settings.download_gating_all": "All downloads — every release download requires a valid license key",
"org.settings.download_gating_help": "Controls whether release file downloads (zip attachments, source archives) require a valid license key.",
"org.settings.support_url": "Support / Product Page URL",
"org.settings.support_url_help": "Shown to users when downloads are gated. Can be your wiki, product page, or external support site. Leave empty to use the default repository page.",
"org.settings.extension_metadata": "Extension Metadata",
"org.settings.extension_metadata_desc": "Configure how this extension appears in update feeds. These fields are used when generating updates.xml, JSON feeds, and package metadata.",
"org.settings.update_platform": "Update Feed Format",
+12
View File
@@ -1346,9 +1346,19 @@ func Routes() *web.Router {
Delete(reqToken(), reqRepoWriter(unit.TypeReleases), repo.DeleteReleaseByTag)
})
}, reqRepoReader(unit.TypeReleases))
m.Group("/license-settings", func() {
m.Combo("").Get(repo.GetLicenseSettings).
Put(bind(api.LicenseSettings{}), repo.UpdateLicenseSettings)
}, reqToken(), reqAdmin())
m.Group("/license-packages", func() {
m.Combo("").Get(repo.ListLicensePackages).
Post(bind(api.CreateLicensePackageOption{}), repo.CreateLicensePackage)
m.Group("/{id}", func() {
m.Patch("", bind(api.EditLicensePackageOption{}), repo.EditLicensePackage)
m.Delete("", repo.DeleteLicensePackage)
m.Post("/archive", repo.ArchiveLicensePackage)
m.Post("/unarchive", repo.UnarchiveLicensePackage)
})
}, reqToken(), reqAdmin())
m.Post("/license-keys/validate", bind(api.ValidateLicenseKeyOption{}), repo.ValidateLicenseKey)
m.Group("/license-keys", func() {
@@ -1358,6 +1368,8 @@ func Routes() *web.Router {
m.Group("/{id}", func() {
m.Delete("", repo.DeleteLicenseKey)
m.Patch("", bind(api.EditLicenseKeyOption{}), repo.EditLicenseKey)
m.Post("/revoke", repo.RevokeLicenseKey)
m.Post("/renew", repo.RenewLicenseKey)
m.Get("/usage", repo.GetLicenseKeyUsage)
})
}, reqToken(), reqAdmin())
+262 -1
View File
@@ -14,6 +14,79 @@ import (
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
)
// GetLicenseSettings returns the licensing/update stream settings for a repo.
func GetLicenseSettings(ctx *context.APIContext) {
cfg := licenses.GetEffectiveConfig(ctx, ctx.Repo.Repository.OwnerID, ctx.Repo.Repository.ID)
if cfg == nil {
ctx.JSON(http.StatusOK, &structs.LicenseSettings{})
return
}
ctx.JSON(http.StatusOK, &structs.LicenseSettings{
LicensingEnabled: cfg.LicensingEnabled,
RequireKey: cfg.RequireKey,
DownloadGating: cfg.DownloadGating,
Platform: cfg.Platform,
SupportURL: cfg.SupportURL,
ExtensionName: cfg.ExtensionName,
DisplayName: cfg.DisplayName,
ExtensionType: cfg.ExtensionType,
Maintainer: cfg.Maintainer,
MaintainerURL: cfg.MaintainerURL,
InfoURL: cfg.InfoURL,
TargetVersion: cfg.TargetVersion,
PHPMinimum: cfg.PHPMinimum,
})
}
// UpdateLicenseSettings saves licensing/update stream settings for a repo.
func UpdateLicenseSettings(ctx *context.APIContext) {
form := web.GetForm(ctx).(*structs.LicenseSettings)
cfg := &licenses.UpdateStreamConfig{
OwnerID: ctx.Repo.Repository.OwnerID,
RepoID: ctx.Repo.Repository.ID,
LicensingEnabled: form.LicensingEnabled,
RequireKey: form.RequireKey,
DownloadGating: form.DownloadGating,
Platform: form.Platform,
SupportURL: form.SupportURL,
ExtensionName: form.ExtensionName,
DisplayName: form.DisplayName,
ExtensionType: form.ExtensionType,
Maintainer: form.Maintainer,
MaintainerURL: form.MaintainerURL,
InfoURL: form.InfoURL,
TargetVersion: form.TargetVersion,
PHPMinimum: form.PHPMinimum,
StreamMode: "joomla",
}
if err := licenses.SaveConfig(ctx, cfg); err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.JSON(http.StatusOK, form)
}
// verifyPackageOwnership checks that a package belongs to the current repo's owner.
func verifyPackageOwnership(ctx *context.APIContext, pkg *licenses.LicensePackage) bool {
if pkg.OwnerID != ctx.Repo.Repository.OwnerID {
ctx.APIErrorNotFound(nil)
return false
}
return true
}
// verifyKeyOwnership checks that a key belongs to the current repo's owner.
func verifyKeyOwnership(ctx *context.APIContext, key *licenses.LicenseKey) bool {
if key.OwnerID != ctx.Repo.Repository.OwnerID {
ctx.APIErrorNotFound(nil)
return false
}
return true
}
func toLicensePackageAPI(pkg *licenses.LicensePackage) *structs.LicensePackage {
return &structs.LicensePackage{
ID: pkg.ID,
@@ -100,6 +173,125 @@ func CreateLicensePackage(ctx *context.APIContext) {
ctx.JSON(http.StatusCreated, toLicensePackageAPI(pkg))
}
// EditLicensePackage edits a license package via API.
func EditLicensePackage(ctx *context.APIContext) {
form := web.GetForm(ctx).(*structs.EditLicensePackageOption)
pkgID := ctx.PathParamInt64("id")
pkg, err := licenses.GetLicensePackageByID(ctx, pkgID)
if err != nil {
ctx.APIErrorNotFound(err)
return
}
if !verifyPackageOwnership(ctx, pkg) {
return
}
if pkg.Name == licenses.MasterPackageName {
ctx.APIError(http.StatusForbidden, "master package cannot be edited")
return
}
if form.Name != nil {
pkg.Name = *form.Name
}
if form.Description != nil {
pkg.Description = *form.Description
}
if form.DurationDays != nil {
pkg.DurationDays = *form.DurationDays
}
if form.MaxSites != nil {
pkg.MaxSites = *form.MaxSites
}
if form.RepoScope != nil {
pkg.RepoScope = *form.RepoScope
}
if form.AllowedChannels != nil {
pkg.AllowedChannels = *form.AllowedChannels
}
if form.IsActive != nil {
pkg.IsActive = *form.IsActive
}
if err := licenses.UpdateLicensePackage(ctx, pkg); err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.JSON(http.StatusOK, toLicensePackageAPI(pkg))
}
// DeleteLicensePackage deletes a license package via API.
func DeleteLicensePackage(ctx *context.APIContext) {
pkgID := ctx.PathParamInt64("id")
pkg, err := licenses.GetLicensePackageByID(ctx, pkgID)
if err != nil {
ctx.APIErrorNotFound(err)
return
}
if !verifyPackageOwnership(ctx, pkg) {
return
}
if pkg.Name == licenses.MasterPackageName {
ctx.APIError(http.StatusForbidden, "master package cannot be deleted")
return
}
if err := licenses.DeleteLicensePackage(ctx, pkgID); err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.Status(http.StatusNoContent)
}
// ArchiveLicensePackage archives a license package via API.
func ArchiveLicensePackage(ctx *context.APIContext) {
pkgID := ctx.PathParamInt64("id")
pkg, err := licenses.GetLicensePackageByID(ctx, pkgID)
if err != nil {
ctx.APIErrorNotFound(err)
return
}
if !verifyPackageOwnership(ctx, pkg) {
return
}
if pkg.Name == licenses.MasterPackageName {
ctx.APIError(http.StatusForbidden, "master package cannot be archived")
return
}
if err := licenses.ArchiveLicensePackage(ctx, pkgID); err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.Status(http.StatusNoContent)
}
// UnarchiveLicensePackage restores an archived license package via API.
func UnarchiveLicensePackage(ctx *context.APIContext) {
pkgID := ctx.PathParamInt64("id")
pkg, err := licenses.GetLicensePackageByID(ctx, pkgID)
if err != nil {
ctx.APIErrorNotFound(err)
return
}
if !verifyPackageOwnership(ctx, pkg) {
return
}
if err := licenses.UnarchiveLicensePackage(ctx, pkgID); err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.Status(http.StatusNoContent)
}
// ListLicenseKeys lists license keys for the repo owner.
func ListLicenseKeys(ctx *context.APIContext) {
keys, err := licenses.ListLicenseKeys(ctx, ctx.Repo.Repository.OwnerID)
@@ -175,6 +367,9 @@ func EditLicenseKey(ctx *context.APIContext) {
ctx.APIErrorNotFound(err)
return
}
if !verifyKeyOwnership(ctx, key) {
return
}
if key.IsInternal {
ctx.APIError(http.StatusForbidden, "master keys cannot be edited")
@@ -230,6 +425,9 @@ func PurchaseLicenseKey(ctx *context.APIContext) {
ctx.APIErrorNotFound(err)
return
}
if !verifyPackageOwnership(ctx, pkg) {
return
}
key := &licenses.LicenseKey{
PackageID: form.PackageID,
@@ -259,9 +457,72 @@ func PurchaseLicenseKey(ctx *context.APIContext) {
ctx.JSON(http.StatusCreated, resp)
}
// RenewLicenseKey extends a key's expiration by its package duration.
func RenewLicenseKey(ctx *context.APIContext) {
keyID := ctx.PathParamInt64("id")
key, err := licenses.GetLicenseKeyByID(ctx, keyID)
if err != nil {
ctx.APIErrorNotFound(err)
return
}
if !verifyKeyOwnership(ctx, key) {
return
}
pkg, err := licenses.GetLicensePackageByID(ctx, key.PackageID)
if err != nil {
ctx.APIErrorNotFound(err)
return
}
days := pkg.DurationDays
if days == 0 {
days = 365
}
if err := licenses.RenewLicenseKey(ctx, keyID, days); err != nil {
ctx.APIErrorInternal(err)
return
}
// Reload key to get updated fields.
key, _ = licenses.GetLicenseKeyByID(ctx, keyID)
ctx.JSON(http.StatusOK, toLicenseKeyAPI(key))
}
// RevokeLicenseKey deactivates a license key via API.
func RevokeLicenseKey(ctx *context.APIContext) {
keyID := ctx.PathParamInt64("id")
key, err := licenses.GetLicenseKeyByID(ctx, keyID)
if err != nil {
ctx.APIErrorNotFound(err)
return
}
if !verifyKeyOwnership(ctx, key) {
return
}
key.IsActive = false
if err := licenses.UpdateLicenseKey(ctx, key); err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.JSON(http.StatusOK, toLicenseKeyAPI(key))
}
// DeleteLicenseKey deletes a license key.
func DeleteLicenseKey(ctx *context.APIContext) {
if err := licenses.DeleteLicenseKey(ctx, ctx.PathParamInt64("id")); err != nil {
keyID := ctx.PathParamInt64("id")
key, err := licenses.GetLicenseKeyByID(ctx, keyID)
if err != nil {
ctx.APIErrorNotFound(err)
return
}
if !verifyKeyOwnership(ctx, key) {
return
}
if err := licenses.DeleteLicenseKey(ctx, keyID); err != nil {
ctx.APIErrorInternal(err)
return
}
+117 -17
View File
@@ -11,6 +11,7 @@ import (
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/perm"
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
unit_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/unit"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/json"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/templates"
@@ -20,6 +21,24 @@ import (
const tplOrgLicenses templates.TplName = "org/licenses"
// OrgChannelItem represents a selectable channel for the combolist template.
type OrgChannelItem struct {
Value string
Label string
}
func buildOrgChannelItems(streams []licenses.StreamDef) []OrgChannelItem {
items := make([]OrgChannelItem, 0, len(streams))
for _, s := range streams {
label := s.Name
if s.Description != "" {
label = s.Name + " — " + s.Description
}
items = append(items, OrgChannelItem{Value: s.Name, Label: label})
}
return items
}
// parseOrgAllowedChannels splits an AllowedChannels string (CSV or JSON array) into a slice.
func parseOrgAllowedChannels(s string) []string {
if s == "" {
@@ -87,7 +106,16 @@ func Licenses(ctx *context.Context) {
}
ctx.Data["LicensePackages"] = display
keys, err := licenses.ListLicenseKeys(ctx, ownerID)
// Search or list keys.
searchQuery := strings.TrimSpace(ctx.FormString("q"))
ctx.Data["SearchQuery"] = searchQuery
var keys []*licenses.LicenseKey
if searchQuery != "" {
keys, err = licenses.SearchLicenseKeys(ctx, ownerID, searchQuery)
} else {
keys, err = licenses.ListLicenseKeys(ctx, ownerID)
}
if err != nil {
ctx.ServerError("ListLicenseKeys", err)
return
@@ -96,14 +124,34 @@ func Licenses(ctx *context.Context) {
ctx.Data["IsRepoAdmin"] = canWriteLicenses
ctx.Data["IsSiteAdmin"] = ctx.IsUserSiteAdmin()
ctx.Data["IsOrganizationOwner"] = ctx.Org.IsOwner
ctx.Data["CanDelete"] = canOrgDeleteLicenses(ctx)
ctx.Data["OrgLicensingEnabled"] = true
orgCfg, _ := licenses.GetOrgConfig(ctx, ownerID)
if orgCfg != nil {
ctx.Data["AvailableStreams"] = orgCfg.GetActiveStreams()
} else {
ctx.Data["AvailableStreams"] = licenses.DefaultJoomlaStreams()
// Load archived packages.
archivedPkgs, _ := licenses.ListArchivedLicensePackages(ctx, ownerID)
var archivedDisplay []LicensePackageDisplay
for _, pkg := range archivedPkgs {
count, _ := licenses.CountKeysByPackage(ctx, pkg.ID)
archivedDisplay = append(archivedDisplay, LicensePackageDisplay{
LicensePackage: pkg,
KeyCount: count,
Created: time.Unix(int64(pkg.CreatedUnix), 0),
})
}
ctx.Data["ArchivedPackages"] = archivedDisplay
orgCfg, _ := licenses.GetOrgConfig(ctx, ownerID)
var orgStreams []licenses.StreamDef
if orgCfg != nil {
orgStreams = orgCfg.GetActiveStreams()
} else {
orgStreams = licenses.DefaultJoomlaStreams()
}
ctx.Data["AvailableStreams"] = orgStreams
ctx.Data["ChannelItems"] = buildOrgChannelItems(orgStreams)
orgRepos, _ := repo_model.GetOrgRepositories(ctx, ownerID)
ctx.Data["OrgRepos"] = orgRepos
ctx.HTML(http.StatusOK, tplOrgLicenses)
}
@@ -119,22 +167,30 @@ func LicensesCreatePackage(ctx *context.Context) {
durationDays, _ := strconv.Atoi(ctx.FormString("duration_days"))
maxSites, _ := strconv.Atoi(ctx.FormString("max_sites"))
domainLockHours, _ := strconv.Atoi(ctx.FormString("domain_lock_hours"))
channels := ctx.Req.Form["allowed_channels"]
channelStr := ctx.FormString("allowed_channels")
var allowedChannels string
if len(channels) > 0 {
data, _ := json.Marshal(channels)
if channelStr != "" {
parts := strings.Split(channelStr, ",")
data, _ := json.Marshal(parts)
allowedChannels = string(data)
}
repoScope := ctx.FormString("repo_scope")
if repoScope == "" {
repoScope = "all"
}
pkg := &licenses.LicensePackage{
OwnerID: ctx.Org.Organization.ID,
Name: name,
Description: ctx.FormString("description"),
DurationDays: durationDays,
MaxSites: maxSites,
DomainLockHours: domainLockHours,
AllowedChannels: allowedChannels,
RepoScope: "all",
RepoScope: repoScope,
IsActive: true,
}
@@ -242,14 +298,19 @@ func LicensesEditPackage(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("repo.licenses.edit_package")
ctx.Data["IsLicensesPage"] = true
ctx.Data["Package"] = pkg
ctx.Data["SelectedChannels"] = parseOrgAllowedChannels(pkg.AllowedChannels)
selectedChannels := parseOrgAllowedChannels(pkg.AllowedChannels)
ctx.Data["SelectedChannels"] = selectedChannels
orgCfg, _ := licenses.GetOrgConfig(ctx, ctx.Org.Organization.ID)
var editStreams []licenses.StreamDef
if orgCfg != nil {
ctx.Data["AvailableStreams"] = orgCfg.GetActiveStreams()
editStreams = orgCfg.GetActiveStreams()
} else {
ctx.Data["AvailableStreams"] = licenses.DefaultJoomlaStreams()
editStreams = licenses.DefaultJoomlaStreams()
}
ctx.Data["AvailableStreams"] = editStreams
ctx.Data["ChannelItems"] = buildOrgChannelItems(editStreams)
ctx.Data["SelectedChannelValues"] = strings.Join(selectedChannels, ",")
ctx.HTML(http.StatusOK, tplOrgLicensesEditPackage)
}
@@ -275,6 +336,8 @@ func LicensesEditPackagePost(ctx *context.Context) {
pkg.DurationDays = durationDays
maxSites, _ := strconv.Atoi(ctx.FormString("max_sites"))
pkg.MaxSites = maxSites
domainLockHours, _ := strconv.Atoi(ctx.FormString("domain_lock_hours"))
pkg.DomainLockHours = domainLockHours
channels := ctx.Req.Form["allowed_channels"]
if len(channels) > 0 {
@@ -295,9 +358,46 @@ func LicensesEditPackagePost(ctx *context.Context) {
ctx.Redirect(ctx.Org.OrgLink + "/-/licenses")
}
// LicensesDeletePackage deletes an org license package. Site admin only.
// canOrgDeleteLicenses returns true if the user is a site admin or org owner.
func canOrgDeleteLicenses(ctx *context.Context) bool {
return ctx.IsUserSiteAdmin() || ctx.Org.IsOwner
}
// LicensesArchivePackage archives an org license package.
func LicensesArchivePackage(ctx *context.Context) {
pkgID := ctx.PathParamInt64("id")
pkg, err := licenses.GetLicensePackageByID(ctx, pkgID)
if err != nil {
ctx.ServerError("GetLicensePackageByID", err)
return
}
if pkg.Name == licenses.MasterPackageName {
ctx.Flash.Error("Master package cannot be archived")
ctx.Redirect(ctx.Org.OrgLink + "/-/licenses")
return
}
if err := licenses.ArchiveLicensePackage(ctx, pkgID); err != nil {
ctx.ServerError("ArchiveLicensePackage", err)
return
}
ctx.Flash.Success(ctx.Tr("repo.licenses.package_archived"))
ctx.Redirect(ctx.Org.OrgLink + "/-/licenses")
}
// LicensesUnarchivePackage restores an archived org license package.
func LicensesUnarchivePackage(ctx *context.Context) {
pkgID := ctx.PathParamInt64("id")
if err := licenses.UnarchiveLicensePackage(ctx, pkgID); err != nil {
ctx.ServerError("UnarchiveLicensePackage", err)
return
}
ctx.Flash.Success(ctx.Tr("repo.licenses.package_unarchived"))
ctx.Redirect(ctx.Org.OrgLink + "/-/licenses")
}
// LicensesDeletePackage permanently deletes an org license package. Site admin or org owner.
func LicensesDeletePackage(ctx *context.Context) {
if !ctx.IsUserSiteAdmin() {
if !canOrgDeleteLicenses(ctx) {
ctx.NotFound(nil)
return
}
@@ -438,9 +538,9 @@ func LicensesRenewKey(ctx *context.Context) {
ctx.Redirect(ctx.Org.OrgLink + "/-/licenses")
}
// LicensesDeleteKey permanently deletes a license key. Site admin only.
// LicensesDeleteKey permanently deletes a license key. Site admin or org owner.
func LicensesDeleteKey(ctx *context.Context) {
if !ctx.IsUserSiteAdmin() {
if !canOrgDeleteLicenses(ctx) {
ctx.NotFound(nil)
return
}
+3
View File
@@ -44,6 +44,9 @@ func SettingsUpdateStreamsPost(ctx *context.Context) {
CustomStreams: ctx.FormString("custom_streams"),
LicensingEnabled: ctx.FormString("licensing_enabled") == "on",
RequireKey: ctx.FormString("require_key") == "on",
FeedVisibility: ctx.FormString("feed_visibility"),
DownloadGating: ctx.FormString("download_gating"),
SupportURL: ctx.FormString("support_url"),
ExtensionName: ctx.FormString("extension_name"),
DisplayName: ctx.FormString("display_name"),
Description: ctx.FormString("feed_description"),
+193
View File
@@ -0,0 +1,193 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package repo
import (
"encoding/xml"
"fmt"
"net/http"
"strings"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses"
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
)
// Joomla changelog XML structures.
type xmlChangelogs struct {
XMLName xml.Name `xml:"changelogs"`
Changelogs []xmlChangelog `xml:"changelog"`
}
type xmlChangelog struct {
Element string `xml:"element"`
Type string `xml:"type"`
Version string `xml:"version"`
Security *xmlItems `xml:"security,omitempty"`
Fix *xmlItems `xml:"fix,omitempty"`
Addition *xmlItems `xml:"addition,omitempty"`
Change *xmlItems `xml:"change,omitempty"`
Remove *xmlItems `xml:"remove,omitempty"`
Note *xmlItems `xml:"note,omitempty"`
}
type xmlItems struct {
Items []string `xml:"item"`
}
// ServeChangelogXML generates Joomla-compatible changelog.xml from release notes.
func ServeChangelogXML(ctx *context.Context) {
repo := ctx.Repo.Repository
releases, err := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{
RepoID: repo.ID,
ListOptions: db.ListOptionsAll,
IncludeDrafts: false,
IncludeTags: false,
})
if err != nil {
ctx.ServerError("FindReleases", err)
return
}
if err := repo.LoadOwner(ctx); err != nil {
ctx.ServerError("LoadOwner", err)
return
}
// Get extension metadata for element name and type.
cfg := licenses.GetEffectiveConfig(ctx, repo.OwnerID, repo.ID)
element := strings.ToLower(repo.Name)
extType := "component"
if cfg != nil {
if cfg.ExtensionName != "" {
element = cfg.ExtensionName
}
if cfg.ExtensionType != "" {
extType = cfg.ExtensionType
}
}
var changelogs xmlChangelogs
for _, rel := range releases {
if rel.IsDraft || rel.IsTag || rel.Note == "" {
continue
}
version := extractVersionFromTag(rel.TagName)
cl := xmlChangelog{
Element: element,
Type: extType,
Version: version,
}
// Parse release notes into categories.
security, fixes, additions, changes, removals, notes := parseReleaseNotes(rel.Note)
if len(security) > 0 {
cl.Security = &xmlItems{Items: security}
}
if len(fixes) > 0 {
cl.Fix = &xmlItems{Items: fixes}
}
if len(additions) > 0 {
cl.Addition = &xmlItems{Items: additions}
}
if len(changes) > 0 {
cl.Change = &xmlItems{Items: changes}
}
if len(removals) > 0 {
cl.Remove = &xmlItems{Items: removals}
}
if len(notes) > 0 {
cl.Note = &xmlItems{Items: notes}
}
// If no categorized items, put the whole note as a single note item.
if cl.Security == nil && cl.Fix == nil && cl.Addition == nil &&
cl.Change == nil && cl.Remove == nil && cl.Note == nil {
cl.Note = &xmlItems{Items: []string{truncate(rel.Note, 200)}}
}
changelogs.Changelogs = append(changelogs.Changelogs, cl)
}
output, err := xml.MarshalIndent(changelogs, "", " ")
if err != nil {
ctx.ServerError("xml.MarshalIndent", err)
return
}
ctx.Resp.Header().Set("Content-Type", "application/xml; charset=utf-8")
ctx.Resp.WriteHeader(http.StatusOK)
_, _ = ctx.Resp.Write([]byte(xml.Header))
_, _ = ctx.Resp.Write(output)
}
// parseReleaseNotes extracts categorized items from Keep-a-Changelog style markdown.
// Supports sections: ### Added, ### Changed, ### Fixed, ### Security, ### Removed, ### Deprecated
func parseReleaseNotes(note string) (security, fixes, additions, changes, removals, notes []string) {
lines := strings.Split(note, "\n")
var currentCategory *[]string
for _, line := range lines {
trimmed := strings.TrimSpace(line)
lower := strings.ToLower(trimmed)
// Detect section headers.
if strings.HasPrefix(lower, "### ") || strings.HasPrefix(lower, "## ") {
section := strings.TrimLeft(lower, "# ")
switch {
case strings.Contains(section, "security"):
currentCategory = &security
case strings.Contains(section, "fix"), strings.Contains(section, "bug"):
currentCategory = &fixes
case strings.Contains(section, "add"), strings.Contains(section, "new"), strings.Contains(section, "feature"):
currentCategory = &additions
case strings.Contains(section, "change"), strings.Contains(section, "update"), strings.Contains(section, "improve"):
currentCategory = &changes
case strings.Contains(section, "remov"), strings.Contains(section, "delet"):
currentCategory = &removals
case strings.Contains(section, "deprecat"), strings.Contains(section, "note"):
currentCategory = &notes
default:
currentCategory = &notes
}
continue
}
// Parse list items (- item or * item).
if (strings.HasPrefix(trimmed, "- ") || strings.HasPrefix(trimmed, "* ")) && len(trimmed) > 2 {
item := strings.TrimLeft(trimmed[2:], " ")
if currentCategory != nil {
*currentCategory = append(*currentCategory, item)
} else {
notes = append(notes, item)
}
}
}
return
}
// extractVersionFromTag strips common prefixes to get a clean version string.
func extractVersionFromTag(tagName string) string {
v := tagName
v = strings.TrimPrefix(v, "v")
v = strings.TrimPrefix(v, "release-")
v = strings.TrimPrefix(v, "release/")
return v
}
func truncate(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "..."
}
// formatChangelogURL returns the changelog.xml URL for use in updates.xml.
func formatChangelogURL(repoLink string) string {
return fmt.Sprintf("%s/changelog.xml", repoLink)
}
+78
View File
@@ -0,0 +1,78 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package repo
import (
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
)
// CheckDownloadGating validates license key for release downloads when gating is enabled.
// Returns true if the download should proceed, false if it should be blocked.
// The tagName parameter is used for prerelease-only gating (empty = always check).
func CheckDownloadGating(ctx *context.Context, tagName string) bool {
if ctx.Repo == nil || ctx.Repo.Repository == nil {
return true
}
repo := ctx.Repo.Repository
// Check effective config (repo override → org default).
cfg := licenses.GetEffectiveConfig(ctx, repo.OwnerID, repo.ID)
if cfg == nil || !cfg.LicensingEnabled {
return true // licensing not enabled — allow all downloads
}
gating := cfg.DownloadGating
if gating == "" || gating == "none" {
return true // no download gating configured
}
// For prerelease-only gating, check if this is a prerelease tag.
if gating == "prerelease" && tagName != "" {
streams := licenses.GetEffectiveStreams(ctx, repo.OwnerID, repo.ID)
matched := licenses.MatchStreamFromTag(tagName, false, streams)
if matched == "stable" {
return true // stable releases are public
}
}
// Gating is active — check for a license key in query params.
rawKey := ctx.FormString("key")
if rawKey == "" {
rawKey = ctx.FormString("download_key")
}
if rawKey == "" {
rawKey = ctx.FormString("dlid")
}
if rawKey == "" {
log.Debug("Download gating: key required but not provided for %s/%s", repo.OwnerName, repo.Name)
return false
}
domain := ctx.FormString("domain")
key, _, err := licenses.ValidateLicenseKeyForRepo(ctx, rawKey, domain, repo.ID)
if err != nil {
log.Debug("Download gating: key validation failed: %v", err)
return false
}
// Record heartbeat on successful download validation.
_ = licenses.TouchHeartbeat(ctx, key.ID)
return true
}
// GetSupportURL returns the configured support URL for a repo, or empty string.
func GetSupportURL(ctx *context.Context) string {
if ctx.Repo == nil || ctx.Repo.Repository == nil {
return ""
}
cfg := licenses.GetEffectiveConfig(ctx, ctx.Repo.Repository.OwnerID, ctx.Repo.Repository.ID)
if cfg != nil && cfg.SupportURL != "" {
return cfg.SupportURL
}
return ""
}
+130 -20
View File
@@ -10,6 +10,7 @@ import (
"time"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses"
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
unit_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/unit"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/json"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/templates"
@@ -40,6 +41,25 @@ func parseAllowedChannels(s string) []string {
return result
}
// ChannelItem represents a selectable channel for the combolist template.
type ChannelItem struct {
Value string
Label string
}
// buildChannelItems converts stream definitions into combolist items.
func buildChannelItems(streams []licenses.StreamDef) []ChannelItem {
items := make([]ChannelItem, 0, len(streams))
for _, s := range streams {
label := s.Name
if s.Description != "" {
label = s.Name + " — " + s.Description
}
items = append(items, ChannelItem{Value: s.Name, Label: label})
}
return items
}
// LicensePackageDisplay is used in templates.
type LicensePackageDisplay struct {
*licenses.LicensePackage
@@ -87,20 +107,50 @@ func Licenses(ctx *context.Context) {
}
ctx.Data["LicensePackages"] = display
keys, err := licenses.ListLicenseKeys(ctx, ownerID)
// Search or list keys.
searchQuery := strings.TrimSpace(ctx.FormString("q"))
ctx.Data["SearchQuery"] = searchQuery
var keys []*licenses.LicenseKey
if searchQuery != "" {
keys, err = licenses.SearchLicenseKeys(ctx, ownerID, searchQuery)
} else {
keys, err = licenses.ListLicenseKeys(ctx, ownerID)
}
if err != nil {
ctx.ServerError("ListLicenseKeys", err)
return
}
ctx.Data["LicenseKeys"] = keys
ctx.Data["CanDelete"] = canDeleteLicenses(ctx)
// Load available streams for the channels multiselect.
orgCfg, _ := licenses.GetOrgConfig(ctx, ownerID)
if orgCfg != nil {
ctx.Data["AvailableStreams"] = orgCfg.GetActiveStreams()
} else {
ctx.Data["AvailableStreams"] = licenses.DefaultJoomlaStreams()
// Load archived packages.
archivedPkgs, _ := licenses.ListArchivedLicensePackages(ctx, ownerID)
var archivedDisplay []LicensePackageDisplay
for _, pkg := range archivedPkgs {
count, _ := licenses.CountKeysByPackage(ctx, pkg.ID)
archivedDisplay = append(archivedDisplay, LicensePackageDisplay{
LicensePackage: pkg,
KeyCount: count,
Created: time.Unix(int64(pkg.CreatedUnix), 0),
})
}
ctx.Data["ArchivedPackages"] = archivedDisplay
// Load available streams for the channels combolist.
orgCfg, _ := licenses.GetOrgConfig(ctx, ownerID)
var streams []licenses.StreamDef
if orgCfg != nil {
streams = orgCfg.GetActiveStreams()
} else {
streams = licenses.DefaultJoomlaStreams()
}
ctx.Data["AvailableStreams"] = streams
ctx.Data["ChannelItems"] = buildChannelItems(streams)
// Load org repos for repo scope selector.
orgRepos, _ := repo_model.GetOrgRepositories(ctx, ownerID)
ctx.Data["OrgRepos"] = orgRepos
ctx.HTML(http.StatusOK, tplLicenses)
}
@@ -116,22 +166,31 @@ func LicensesCreatePackage(ctx *context.Context) {
durationDays, _ := strconv.Atoi(ctx.FormString("duration_days"))
maxSites, _ := strconv.Atoi(ctx.FormString("max_sites"))
domainLockHours, _ := strconv.Atoi(ctx.FormString("domain_lock_hours"))
channels := ctx.Req.Form["allowed_channels"]
channelStr := ctx.FormString("allowed_channels")
var allowedChannels string
if len(channels) > 0 {
data, _ := json.Marshal(channels)
if channelStr != "" {
// Combolist sends comma-separated values; store as JSON array.
parts := strings.Split(channelStr, ",")
data, _ := json.Marshal(parts)
allowedChannels = string(data)
}
repoScope := ctx.FormString("repo_scope")
if repoScope == "" {
repoScope = "all"
}
pkg := &licenses.LicensePackage{
OwnerID: ctx.Repo.Repository.OwnerID,
Name: name,
Description: ctx.FormString("description"),
DurationDays: durationDays,
MaxSites: maxSites,
DomainLockHours: domainLockHours,
AllowedChannels: allowedChannels,
RepoScope: "all",
RepoScope: repoScope,
IsActive: true,
}
@@ -212,11 +271,14 @@ func LicensesGenerateKey(ctx *context.Context) {
ctx.Data["LicenseKeys"] = keys
orgCfg, _ := licenses.GetOrgConfig(ctx, ownerID)
var genStreams []licenses.StreamDef
if orgCfg != nil {
ctx.Data["AvailableStreams"] = orgCfg.GetActiveStreams()
genStreams = orgCfg.GetActiveStreams()
} else {
ctx.Data["AvailableStreams"] = licenses.DefaultJoomlaStreams()
genStreams = licenses.DefaultJoomlaStreams()
}
ctx.Data["AvailableStreams"] = genStreams
ctx.Data["ChannelItems"] = buildChannelItems(genStreams)
ctx.HTML(http.StatusOK, tplLicenses)
}
@@ -332,15 +394,20 @@ func LicensesEditPackage(ctx *context.Context) {
ctx.Data["PageIsLicenses"] = true
ctx.Data["IsLicensesPage"] = true
ctx.Data["Package"] = pkg
ctx.Data["SelectedChannels"] = parseAllowedChannels(pkg.AllowedChannels)
selectedChannels := parseAllowedChannels(pkg.AllowedChannels)
ctx.Data["SelectedChannels"] = selectedChannels
ownerID := ctx.Repo.Repository.OwnerID
orgCfg, _ := licenses.GetOrgConfig(ctx, ownerID)
var editStreams []licenses.StreamDef
if orgCfg != nil {
ctx.Data["AvailableStreams"] = orgCfg.GetActiveStreams()
editStreams = orgCfg.GetActiveStreams()
} else {
ctx.Data["AvailableStreams"] = licenses.DefaultJoomlaStreams()
editStreams = licenses.DefaultJoomlaStreams()
}
ctx.Data["AvailableStreams"] = editStreams
ctx.Data["ChannelItems"] = buildChannelItems(editStreams)
ctx.Data["SelectedChannelValues"] = strings.Join(selectedChannels, ",")
ctx.HTML(http.StatusOK, tplLicensesEditPackage)
}
@@ -366,6 +433,12 @@ func LicensesEditPackagePost(ctx *context.Context) {
pkg.DurationDays = durationDays
maxSites, _ := strconv.Atoi(ctx.FormString("max_sites"))
pkg.MaxSites = maxSites
domainLockHours, _ := strconv.Atoi(ctx.FormString("domain_lock_hours"))
pkg.DomainLockHours = domainLockHours
repoScope := ctx.FormString("repo_scope")
if repoScope != "" {
pkg.RepoScope = repoScope
}
channels := ctx.Req.Form["allowed_channels"]
if len(channels) > 0 {
@@ -386,9 +459,46 @@ func LicensesEditPackagePost(ctx *context.Context) {
ctx.Redirect(ctx.Repo.RepoLink + "/licenses")
}
// LicensesDeletePackage deletes a license package. Site admin only.
// canDeleteLicenses returns true if the user is a site admin or repo owner.
func canDeleteLicenses(ctx *context.Context) bool {
return ctx.IsUserSiteAdmin() || ctx.Repo.Permission.IsOwner()
}
// LicensesArchivePackage archives a license package.
func LicensesArchivePackage(ctx *context.Context) {
pkgID := ctx.PathParamInt64("id")
pkg, err := licenses.GetLicensePackageByID(ctx, pkgID)
if err != nil {
ctx.ServerError("GetLicensePackageByID", err)
return
}
if pkg.Name == licenses.MasterPackageName {
ctx.Flash.Error("Master package cannot be archived")
ctx.Redirect(ctx.Repo.RepoLink + "/licenses")
return
}
if err := licenses.ArchiveLicensePackage(ctx, pkgID); err != nil {
ctx.ServerError("ArchiveLicensePackage", err)
return
}
ctx.Flash.Success(ctx.Tr("repo.licenses.package_archived"))
ctx.Redirect(ctx.Repo.RepoLink + "/licenses")
}
// LicensesUnarchivePackage removes archive status from a package.
func LicensesUnarchivePackage(ctx *context.Context) {
pkgID := ctx.PathParamInt64("id")
if err := licenses.UnarchiveLicensePackage(ctx, pkgID); err != nil {
ctx.ServerError("UnarchiveLicensePackage", err)
return
}
ctx.Flash.Success(ctx.Tr("repo.licenses.package_unarchived"))
ctx.Redirect(ctx.Repo.RepoLink + "/licenses")
}
// LicensesDeletePackage permanently deletes a license package. Site admin or repo owner.
func LicensesDeletePackage(ctx *context.Context) {
if !ctx.IsUserSiteAdmin() {
if !canDeleteLicenses(ctx) {
ctx.NotFound(nil)
return
}
@@ -441,9 +551,9 @@ func LicensesRenewKey(ctx *context.Context) {
ctx.Redirect(ctx.Repo.RepoLink + "/licenses")
}
// LicensesDeleteKey permanently deletes a license key. Site admin only.
// LicensesDeleteKey permanently deletes a license key. Site admin or repo owner.
func LicensesDeleteKey(ctx *context.Context) {
if !ctx.IsUserSiteAdmin() {
if !canDeleteLicenses(ctx) {
ctx.NotFound(nil)
return
}
+7
View File
@@ -147,6 +147,13 @@ func getReleaseInfos(ctx *context.Context, opts *repo_model.FindReleasesOptions)
// Releases render releases list page
func Releases(ctx *context.Context) {
// When licensing is enabled with "hidden" feed visibility, releases require login.
// With "no-download" mode, the page is visible but files are hidden (handled in template).
if ctx.Data["ReleasesRequireLogin"] == true && !ctx.IsSigned {
ctx.Redirect(setting.AppSubURL + "/user/login?redirect_to=" + ctx.Req.URL.RequestURI())
return
}
ctx.Data["PageIsReleaseList"] = true
ctx.Data["Title"] = ctx.Tr("repo.release.releases")
+19
View File
@@ -319,6 +319,13 @@ func RedirectDownload(ctx *context.Context) {
vTag = ctx.PathParam("vTag")
fileName = ctx.PathParam("fileName")
)
// License key gating for release downloads.
if !CheckDownloadGating(ctx, vTag) {
ctx.HTTPError(http.StatusForbidden, "Valid license key required to download this release")
return
}
tagNames := []string{vTag}
curRepo := ctx.Repo.Repository
releases, err := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{
@@ -368,6 +375,18 @@ func Download(ctx *context.Context) {
return
}
// License key gating for archive downloads.
// Extract tag from the archive ref (e.g. "v1.0.0.zip" → "v1.0.0").
archiveRef := ctx.PathParam("*")
tagForGating := archiveRef
for _, ext := range []string{".zip", ".tar.gz", ".tar", ".gz", ".bundle"} {
tagForGating = strings.TrimSuffix(tagForGating, ext)
}
if !CheckDownloadGating(ctx, tagForGating) {
ctx.HTTPError(http.StatusForbidden, "Valid license key required to download this archive")
return
}
aReq, err := archiver_service.NewRequest(ctx.Repo.Repository, ctx.Repo.GitRepo, ctx.PathParam("*"), ctx.FormStrings("path"))
if err != nil {
if errors.Is(err, util.ErrInvalidArgument) {
+8
View File
@@ -686,6 +686,14 @@ func handleSettingsPostAdvanced(ctx *context.Context) {
Platform: updatePlatform,
LicensingEnabled: form.EnableLicensing,
RequireKey: form.RequireUpdateKey,
DownloadGating: form.DownloadGating,
SupportURL: form.SupportURL,
ExtensionName: form.ExtensionName,
DisplayName: form.DisplayName,
ExtensionType: form.ExtensionType,
TargetVersion: form.TargetVersion,
Maintainer: form.Maintainer,
PHPMinimum: form.PHPMinimum,
StreamMode: "joomla", // inherit org default
}
if err := licenses_model.SaveConfig(ctx, updateCfg); err != nil {
+89 -26
View File
@@ -15,8 +15,9 @@ import (
)
// validateUpdateKey checks for a license key in the request and validates it.
// Returns allowed channels (nil = all channels) and whether access is granted.
func validateUpdateKey(ctx *context.Context) (allowedChannels []string, ok bool) {
// Returns allowed channels (nil = all channels), whether access is granted,
// and whether download URLs should be stripped (for "no-download" feed visibility).
func validateUpdateKey(ctx *context.Context) (allowedChannels []string, ok bool, stripDownloads bool) {
rawKey := ctx.FormString("key")
if rawKey == "" {
rawKey = ctx.FormString("download_key")
@@ -26,21 +27,38 @@ func validateUpdateKey(ctx *context.Context) (allowedChannels []string, ok bool)
}
if rawKey == "" {
// Check if this repo requires a key for update feed access.
repoCfg, _ := licenses.GetRepoConfig(ctx, ctx.Repo.Repository.ID)
if repoCfg != nil && repoCfg.RequireKey {
// Key required but not provided — return empty.
return nil, false
cfg := licenses.GetEffectiveConfig(ctx, ctx.Repo.Repository.OwnerID, ctx.Repo.Repository.ID)
feedVis := "public"
requireKey := false
if cfg != nil {
requireKey = cfg.RequireKey
if cfg.FeedVisibility != "" {
feedVis = cfg.FeedVisibility
}
}
if requireKey {
switch feedVis {
case "hidden":
// Fully hidden — return empty feed.
return nil, false, false
case "no-download":
// Show versions but strip download URLs.
return nil, true, true
default:
// "public" with RequireKey — still hide feed (backward compat).
return nil, false, false
}
}
// No key required — allow public access (all channels).
return nil, true
return nil, true, false
}
domain := ctx.FormString("domain")
key, pkg, err := licenses.ValidateLicenseKey(ctx, rawKey, domain)
key, pkg, err := licenses.ValidateLicenseKeyForRepo(ctx, rawKey, domain, ctx.Repo.Repository.ID)
if err != nil {
log.Debug("License key validation failed: %v", err)
return nil, false
return nil, false, false
}
// Update heartbeat and record usage.
@@ -56,26 +74,27 @@ func validateUpdateKey(ctx *context.Context) (allowedChannels []string, ok bool)
// Parse allowed channels from the package.
if pkg.AllowedChannels != "" {
channels := strings.Split(pkg.AllowedChannels, ",")
var channels []string
if strings.HasPrefix(pkg.AllowedChannels, "[") {
// JSON array format — parse first to avoid substring issues.
if err := json.Unmarshal([]byte(pkg.AllowedChannels), &channels); err != nil {
channels = strings.Split(pkg.AllowedChannels, ",")
}
} else {
channels = strings.Split(pkg.AllowedChannels, ",")
}
for i := range channels {
channels[i] = strings.TrimSpace(channels[i])
}
// Also try JSON array format.
if strings.HasPrefix(pkg.AllowedChannels, "[") {
var parsed []string
if err := json.Unmarshal([]byte(pkg.AllowedChannels), &parsed); err == nil {
channels = parsed
}
}
// Normalize shorthand names to full Joomla convention.
for i := range channels {
channels[i] = updateserver.NormalizeChannel(channels[i])
}
return channels, true
return channels, true, false
}
// Master/internal keys or packages with no channel restriction — all channels.
return nil, true
return nil, true, false
}
// ServeUpdatesXML generates and serves a Joomla-compatible updates.xml
@@ -88,20 +107,18 @@ func ServeUpdatesXML(ctx *context.Context) {
return
}
allowedChannels, ok := validateUpdateKey(ctx)
allowedChannels, ok, stripDownloads := validateUpdateKey(ctx)
if !ok {
// Return empty updates XML for invalid keys (Joomla-compatible).
ctx.Resp.Header().Set("Content-Type", "application/xml; charset=utf-8")
ctx.Resp.WriteHeader(http.StatusOK)
_, _ = ctx.Resp.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?><updates></updates>`))
return
}
// Check if this repo requires a license key for update feed access.
repoCfg, _ := licenses.GetRepoConfig(ctx, ctx.Repo.Repository.ID)
requireKey := repoCfg != nil && repoCfg.RequireKey
xmlData, err := updateserver.GenerateJoomlaXML(ctx, ctx.Repo.Repository, requireKey, allowedChannels...)
xmlData, err := updateserver.GenerateJoomlaXML(ctx, ctx.Repo.Repository, requireKey, stripDownloads, allowedChannels...)
if err != nil {
ctx.ServerError("GenerateJoomlaXML", err)
return
@@ -123,9 +140,8 @@ func ServeDolibarrJSON(ctx *context.Context) {
return
}
allowedChannels, ok := validateUpdateKey(ctx)
allowedChannels, ok, _ := validateUpdateKey(ctx)
if !ok {
// Return empty updates for invalid keys.
ctx.Resp.Header().Set("Content-Type", "application/json; charset=utf-8")
ctx.Resp.WriteHeader(http.StatusOK)
_, _ = ctx.Resp.Write([]byte(`{"module":"","updates":[]}`))
@@ -148,3 +164,50 @@ func ServeDolibarrJSON(ctx *context.Context) {
ctx.Resp.WriteHeader(http.StatusOK)
_, _ = ctx.Resp.Write(jsonData)
}
// ServeWordPressJSON generates and serves a WordPress PUC-compatible update feed.
// Compatible with the YahnisElsts plugin-update-checker library.
func ServeWordPressJSON(ctx *context.Context) {
// Block if platform doesn't include wordpress.
platform := ctx.Data["RepoUpdatePlatform"]
if platform != "wordpress" && platform != "both" {
ctx.NotFound(nil)
return
}
_, ok, stripDownloads := validateUpdateKey(ctx)
if !ok {
ctx.Resp.Header().Set("Content-Type", "application/json; charset=utf-8")
ctx.Resp.WriteHeader(http.StatusOK)
_, _ = ctx.Resp.Write([]byte(`{"name":"","slug":"","version":"0.0.0"}`))
return
}
// Extract license key for embedding in download URL.
licenseKey := ctx.FormString("license_key")
if licenseKey == "" {
licenseKey = ctx.FormString("dlid")
}
if licenseKey == "" {
licenseKey = ctx.FormString("key")
}
if stripDownloads {
licenseKey = "" // strip download URLs by clearing the key
}
data, err := updateserver.GenerateWordPressJSON(ctx, ctx.Repo.Repository, licenseKey)
if err != nil {
ctx.ServerError("GenerateWordPressJSON", err)
return
}
jsonData, err := json.MarshalIndent(data, "", " ")
if err != nil {
ctx.ServerError("json.Marshal", err)
return
}
ctx.Resp.Header().Set("Content-Type", "application/json; charset=utf-8")
ctx.Resp.WriteHeader(http.StatusOK)
_, _ = ctx.Resp.Write(jsonData)
}
+6
View File
@@ -1110,6 +1110,8 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Get("/packages/{id}/edit", org.LicensesEditPackage)
m.Post("/packages/{id}/edit", org.LicensesEditPackagePost)
m.Post("/packages/{id}/delete", org.LicensesDeletePackage)
m.Post("/packages/{id}/archive", org.LicensesArchivePackage)
m.Post("/packages/{id}/unarchive", org.LicensesUnarchivePackage)
m.Post("/keys/generate", org.LicensesGenerateKey)
m.Get("/keys/{id}/edit", org.LicensesEditKey)
m.Post("/keys/{id}/edit", org.LicensesEditKeyPost)
@@ -1516,6 +1518,8 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Group("/{username}/{reponame}", func() {
m.Get("/updates.xml", repo.ServeUpdatesXML)
m.Get("/updates/dolibarr.json", repo.ServeDolibarrJSON)
m.Get("/updates/wordpress.json", repo.ServeWordPressJSON)
m.Get("/changelog.xml", repo.ServeChangelogXML)
}, optSignIn, context.RepoAssignment)
// end "/{username}/{reponame}": update server
@@ -1528,6 +1532,8 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Get("/packages/{id}/edit", repo.LicensesEditPackage)
m.Post("/packages/{id}/edit", repo.LicensesEditPackagePost)
m.Post("/packages/{id}/delete", repo.LicensesDeletePackage)
m.Post("/packages/{id}/archive", repo.LicensesArchivePackage)
m.Post("/packages/{id}/unarchive", repo.LicensesUnarchivePackage)
m.Post("/keys/generate", repo.LicensesGenerateKey)
m.Get("/keys/{id}/edit", repo.LicensesEditKey)
m.Post("/keys/{id}/edit", repo.LicensesEditKeyPost)
+14
View File
@@ -618,6 +618,20 @@ func repoAssignmentPrepareTemplateData(ctx *Context, data *repoAssignmentPrepare
ctx.Data["NumLicensePackages"] = numLicensePackages
ctx.Data["EnableLicenses"] = licensingEnabled || numLicensePackages > 0
ctx.Data["LicensingEnabled"] = licensingEnabled
// Determine release page access based on feed visibility mode.
feedVis := "public"
if orgCfg != nil && orgCfg.FeedVisibility != "" {
feedVis = orgCfg.FeedVisibility
}
if repoUpdateCfg != nil && repoUpdateCfg.FeedVisibility != "" {
feedVis = repoUpdateCfg.FeedVisibility
}
ctx.Data["FeedVisibility"] = feedVis
// Only "hidden" mode requires login. "no-download" shows page but hides files.
ctx.Data["ReleasesRequireLogin"] = licensingEnabled && feedVis == "hidden"
// Hide download attachments for anonymous users in "no-download" mode.
ctx.Data["HideReleaseDownloads"] = licensingEnabled && feedVis == "no-download" && !ctx.IsSigned
ctx.Data["IsRepoAdmin"] = ctx.Repo.Permission.IsAdmin()
ctx.Data["IsSiteAdmin"] = ctx.IsUserSiteAdmin()
+8
View File
@@ -136,6 +136,14 @@ type RepoSettingForm struct {
UpdatePlatform string
RequireUpdateKey bool
EnableLicensing bool
DownloadGating string
SupportURL string
ExtensionName string
DisplayName string
ExtensionType string
TargetVersion string
Maintainer string
PHPMinimum string
EnablePackages bool
+71 -11
View File
@@ -7,12 +7,14 @@ import (
"context"
"encoding/xml"
"fmt"
"io"
"strings"
"time"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses"
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/storage"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/setting"
)
@@ -105,6 +107,25 @@ func channelFromTag(tagName string, isPrerelease bool) string {
}
}
// joomlaTagName maps internal stream names to Joomla-standard tag values.
// Joomla recognizes: dev, alpha, beta, rc, stable.
func joomlaTagName(channel string) string {
switch channel {
case ChannelDevelopment:
return "dev"
case ChannelAlpha:
return "alpha"
case ChannelBeta:
return "beta"
case ChannelReleaseCandidate:
return "rc"
case ChannelStable:
return "stable"
default:
return channel
}
}
// NormalizeChannel maps shorthand channel names to the full Joomla convention.
// Accepts both "rc" and "release-candidate", "dev" and "development", etc.
func NormalizeChannel(ch string) string {
@@ -128,7 +149,7 @@ func NormalizeChannel(ch string) string {
// It returns the raw XML bytes. Extension metadata is read from the update stream config;
// falls back to repo name/owner when not configured.
// allowedChannels optionally restricts output to specific channels (nil = all).
func GenerateJoomlaXML(ctx context.Context, repo *repo_model.Repository, requireKey bool, allowedChannels ...string) ([]byte, error) {
func GenerateJoomlaXML(ctx context.Context, repo *repo_model.Repository, requireKey, stripDownloads bool, allowedChannels ...string) ([]byte, error) {
releases, err := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{
RepoID: repo.ID,
ListOptions: db.ListOptionsAll,
@@ -227,14 +248,25 @@ func GenerateJoomlaXML(ctx context.Context, repo *repo_model.Repository, require
continue
}
// Find the first .zip attachment as the download URL.
var downloadURL string
// Find the first .zip attachment as the download URL, and its SHA256 sidecar.
var downloadURL, sha256Hash string
var zipName string
for _, att := range rel.Attachments {
if strings.HasSuffix(strings.ToLower(att.Name), ".zip") {
if strings.HasSuffix(strings.ToLower(att.Name), ".zip") && !strings.HasSuffix(att.Name, ".sha256") {
downloadURL = fmt.Sprintf("%s/releases/download/%s/%s", repoLink, rel.TagName, att.Name)
zipName = att.Name
break
}
}
// Look for the SHA256 sidecar file.
if zipName != "" {
for _, att := range rel.Attachments {
if att.Name == zipName+".sha256" {
sha256Hash = readSHA256FromSidecar(ctx, att)
break
}
}
}
// Fall back to the release tag archive if no zip attachment.
if downloadURL == "" {
downloadURL = fmt.Sprintf("%s/archive/%s.zip", repoLink, rel.TagName)
@@ -254,8 +286,10 @@ func GenerateJoomlaXML(ctx context.Context, repo *repo_model.Repository, require
desc = fmt.Sprintf("%s %s build.", displayName, ch)
}
infoURL := fmt.Sprintf("%s/releases/tag/%s", repoLink, rel.TagName)
if cfg != nil && cfg.InfoURL != "" {
infoURL := fmt.Sprintf("%s/releases", repoLink)
if cfg != nil && cfg.SupportURL != "" {
infoURL = cfg.SupportURL
} else if cfg != nil && cfg.InfoURL != "" {
infoURL = cfg.InfoURL
}
@@ -272,15 +306,19 @@ func GenerateJoomlaXML(ctx context.Context, repo *repo_model.Repository, require
URL: infoURL,
},
Downloads: xmlDownloads{
DownloadURL: []xmlDownloadURL{
{Type: "full", Format: "zip", URL: downloadURL},
},
DownloadURL: func() []xmlDownloadURL {
if stripDownloads {
return nil
}
return []xmlDownloadURL{{Type: "full", Format: "zip", URL: downloadURL}}
}(),
},
Tags: xmlTags{Tag: ch},
ChangelogURL: fmt.Sprintf("%s/raw/branch/%s/CHANGELOG.md", repoLink, repo.DefaultBranch),
Tags: xmlTags{Tag: joomlaTagName(ch)},
ChangelogURL: fmt.Sprintf("%s/changelog.xml", repoLink),
Maintainer: maintainer,
MaintainerURL: maintainerURL,
PHPMinimum: phpMinimum,
SHA256: sha256Hash,
TargetPlatform: xmlTargetPlat{
Name: "joomla",
Version: targetVersion,
@@ -333,3 +371,25 @@ func channelSuffix(channel string) string {
return ""
}
}
// readSHA256FromSidecar reads the SHA256 hash from a .sha256 sidecar attachment.
// The sidecar format is: "<hex> <filename>\n"
func readSHA256FromSidecar(_ context.Context, att *repo_model.Attachment) string {
fr, err := storage.Attachments.Open(att.RelativePath())
if err != nil {
return ""
}
defer fr.Close()
data, err := io.ReadAll(io.LimitReader(fr, 256))
if err != nil {
return ""
}
content := strings.TrimSpace(string(data))
// Format: "hexhash filename"
if idx := strings.Index(content, " "); idx > 0 {
return content[:idx]
}
return content
}
+224
View File
@@ -0,0 +1,224 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package updateserver
import (
"context"
"fmt"
"html"
"strings"
"time"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses"
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/setting"
)
// WordPressPluginInfo is the JSON response format compatible with the
// YahnisElsts plugin-update-checker (PUC) library — the standard for
// commercial WordPress plugin self-hosted updates.
type WordPressPluginInfo struct {
Name string `json:"name"`
Slug string `json:"slug"`
Version string `json:"version"`
DownloadURL string `json:"download_url,omitempty"`
Homepage string `json:"homepage,omitempty"`
Requires string `json:"requires,omitempty"`
Tested string `json:"tested,omitempty"`
RequiresPHP string `json:"requires_php,omitempty"`
Author string `json:"author,omitempty"`
AuthorHomepage string `json:"author_homepage,omitempty"`
LastUpdated string `json:"last_updated,omitempty"`
UpgradeNotice string `json:"upgrade_notice,omitempty"`
Sections map[string]string `json:"sections,omitempty"`
Icons map[string]string `json:"icons,omitempty"`
Banners map[string]string `json:"banners,omitempty"`
}
// GenerateWordPressJSON builds a PUC-compatible JSON response for the latest
// stable release. The license key (if provided) is embedded in the download URL.
func GenerateWordPressJSON(ctx context.Context, repo *repo_model.Repository, licenseKey string) (*WordPressPluginInfo, error) {
releases, err := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{
RepoID: repo.ID,
ListOptions: db.ListOptionsAll,
IncludeDrafts: false,
IncludeTags: false,
})
if err != nil {
return nil, fmt.Errorf("FindReleases: %w", err)
}
if err := repo.LoadOwner(ctx); err != nil {
return nil, fmt.Errorf("LoadOwner: %w", err)
}
baseURL := strings.TrimSuffix(setting.AppURL, "/")
repoLink := fmt.Sprintf("%s/%s/%s", baseURL, repo.Owner.Name, repo.Name)
// Load extension metadata.
cfg := licenses.GetEffectiveConfig(ctx, repo.OwnerID, repo.ID)
slug := strings.ToLower(repo.Name)
displayName := fmt.Sprintf("%s - %s", repo.Owner.Name, repo.Name)
maintainer := repo.Owner.Name
maintainerURL := fmt.Sprintf("%s/%s", baseURL, repo.Owner.Name)
homepage := repoLink
requiresPHP := ""
if cfg != nil {
if cfg.ExtensionName != "" {
slug = cfg.ExtensionName
}
if cfg.DisplayName != "" {
displayName = cfg.DisplayName
}
if cfg.Maintainer != "" {
maintainer = cfg.Maintainer
}
if cfg.MaintainerURL != "" {
maintainerURL = cfg.MaintainerURL
}
if cfg.SupportURL != "" {
homepage = cfg.SupportURL
} else if cfg.InfoURL != "" {
homepage = cfg.InfoURL
}
if cfg.PHPMinimum != "" {
requiresPHP = cfg.PHPMinimum
}
}
// Resolve streams and find the latest stable release.
streams := licenses.GetEffectiveStreams(ctx, repo.OwnerID, repo.ID)
var latestStable *repo_model.Release
for _, rel := range releases {
if rel.IsDraft || rel.IsTag {
continue
}
ch := licenses.MatchStreamFromTag(rel.TagName, rel.IsPrerelease, streams)
if ch == "stable" {
if latestStable == nil || rel.CreatedUnix > latestStable.CreatedUnix {
latestStable = rel
}
}
}
if latestStable == nil {
return &WordPressPluginInfo{
Name: displayName,
Slug: slug,
Version: "0.0.0",
}, nil
}
// Load attachments.
if err := latestStable.LoadAttributes(ctx); err != nil {
return nil, fmt.Errorf("LoadAttributes: %w", err)
}
// Find zip download URL.
var downloadURL string
for _, att := range latestStable.Attachments {
if strings.HasSuffix(strings.ToLower(att.Name), ".zip") && !strings.HasSuffix(att.Name, ".sha256") {
downloadURL = fmt.Sprintf("%s/releases/download/%s/%s", repoLink, latestStable.TagName, att.Name)
break
}
}
if downloadURL == "" {
downloadURL = fmt.Sprintf("%s/archive/%s.zip", repoLink, latestStable.TagName)
}
// Append license key to download URL if provided.
if licenseKey != "" {
if strings.Contains(downloadURL, "?") {
downloadURL += "&dlid=" + licenseKey
} else {
downloadURL += "?dlid=" + licenseKey
}
}
version := extractVersion(latestStable.TagName)
lastUpdated := time.Unix(int64(latestStable.CreatedUnix), 0).Format("2006-01-02 3:04pm MST")
// Build sections from release notes.
sections := map[string]string{}
if latestStable.Note != "" {
sections["changelog"] = buildWordPressChangelog(releases, streams)
}
if cfg != nil && cfg.Description != "" {
sections["description"] = "<p>" + html.EscapeString(cfg.Description) + "</p>"
}
// Build icon/banner URLs from repo assets.
icons := buildAssetURLs(repoLink, repo.DefaultBranch, "icon", []string{"128x128", "256x256"})
banners := buildBannerURLs(repoLink, repo.DefaultBranch)
return &WordPressPluginInfo{
Name: displayName,
Slug: slug,
Version: version,
DownloadURL: downloadURL,
Homepage: homepage,
RequiresPHP: requiresPHP,
Author: maintainer,
AuthorHomepage: maintainerURL,
LastUpdated: lastUpdated,
Sections: sections,
Icons: icons,
Banners: banners,
}, nil
}
// buildWordPressChangelog builds an HTML changelog from multiple releases.
func buildWordPressChangelog(releases []*repo_model.Release, streams []licenses.StreamDef) string {
var b strings.Builder
count := 0
for _, rel := range releases {
if rel.IsDraft || rel.IsTag || rel.Note == "" {
continue
}
ch := licenses.MatchStreamFromTag(rel.TagName, rel.IsPrerelease, streams)
if ch != "stable" {
continue
}
version := extractVersion(rel.TagName)
date := time.Unix(int64(rel.CreatedUnix), 0).Format("2006-01-02")
b.WriteString(fmt.Sprintf("<h4>%s - %s</h4>\n", version, date))
// Convert markdown list items to HTML list.
lines := strings.Split(rel.Note, "\n")
b.WriteString("<ul>\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "- ") || strings.HasPrefix(trimmed, "* ") {
b.WriteString(fmt.Sprintf("<li>%s</li>\n", html.EscapeString(strings.TrimLeft(trimmed[2:], " "))))
}
}
b.WriteString("</ul>\n")
count++
if count >= 10 {
break // limit to last 10 releases
}
}
return b.String()
}
// buildAssetURLs generates icon URLs from conventional paths in the repo.
func buildAssetURLs(repoLink, branch, prefix string, sizes []string) map[string]string {
icons := make(map[string]string)
for i, size := range sizes {
key := fmt.Sprintf("%dx", i+1)
icons[key] = fmt.Sprintf("%s/raw/branch/%s/assets/%s-%s.png", repoLink, branch, prefix, size)
}
return icons
}
// buildBannerURLs generates banner URLs from conventional paths.
func buildBannerURLs(repoLink, branch string) map[string]string {
return map[string]string{
"low": fmt.Sprintf("%s/raw/branch/%s/assets/banner-772x250.png", repoLink, branch),
"high": fmt.Sprintf("%s/raw/branch/%s/assets/banner-1544x500.png", repoLink, branch),
}
}
+115 -20
View File
@@ -45,30 +45,39 @@
<input name="description" placeholder="e.g. Annual pro subscription">
</div>
</div>
<div class="three fields">
<div class="field">
<div class="fields">
<div class="four wide field">
<label>{{ctx.Locale.Tr "repo.licenses.duration"}} ({{ctx.Locale.Tr "repo.licenses.days"}})</label>
<input name="duration_days" type="number" value="0" min="0">
<p class="help">0 = {{ctx.Locale.Tr "repo.licenses.lifetime"}}</p>
</div>
<div class="field">
<div class="four wide field">
<label>{{ctx.Locale.Tr "repo.licenses.max_sites"}}</label>
<input name="max_sites" type="number" value="0" min="0">
<p class="help">0 = {{ctx.Locale.Tr "repo.licenses.unlimited"}}</p>
</div>
<div class="four wide field">
<label>{{ctx.Locale.Tr "repo.licenses.domain_lock_hours"}}</label>
<input name="domain_lock_hours" type="number" value="0" min="0">
<p class="help">{{ctx.Locale.Tr "repo.licenses.domain_lock_hours_help"}}</p>
</div>
<div class="field">
<label>{{ctx.Locale.Tr "repo.licenses.channels"}}</label>
{{if $.AvailableStreams}}
{{range $.AvailableStreams}}
<div class="ui checkbox tw-mr-4 tw-mb-2">
<input name="allowed_channels" type="checkbox" value="{{.Name}}">
<label>{{.Name}}{{if .Description}} <small class="text grey">({{.Description}})</small>{{end}}</label>
</div>
{{end}}
{{end}}
{{template "shared/combolist" dict "Name" "allowed_channels" "Title" (ctx.Locale.Tr "repo.licenses.channels") "Items" $.ChannelItems "SelectedValues" "" "EmptyText" (ctx.Locale.Tr "repo.licenses.all_channels") "Icon" "octicon-tag"}}
<p class="help">{{ctx.Locale.Tr "repo.licenses.channels_help"}}</p>
</div>
</div>
<div class="field">
<label>{{ctx.Locale.Tr "repo.licenses.repo_scope"}}</label>
<select name="repo_scope" class="ui dropdown">
<option value="all">{{ctx.Locale.Tr "repo.licenses.repo_scope_all"}}</option>
{{if $.OrgRepos}}
{{range $.OrgRepos}}
<option value="{{.ID}}">{{.Name}}</option>
{{end}}
{{end}}
</select>
<p class="help">{{ctx.Locale.Tr "repo.licenses.repo_scope_help"}}</p>
</div>
<button class="ui primary button" type="submit">{{ctx.Locale.Tr "repo.licenses.create_package"}}</button>
</form>
</div>
@@ -110,8 +119,11 @@
<a class="ui tiny button" href="{{$.Org.HomeLink}}/-/licenses/packages/{{.ID}}/edit" title="{{ctx.Locale.Tr "repo.licenses.edit_package"}}">
{{svg "octicon-pencil" 14}}
</a>
{{if $.IsSiteAdmin}}
<button class="ui tiny red button link-action" data-url="{{$.Org.HomeLink}}/-/licenses/packages/{{.ID}}/delete" data-modal-confirm="{{ctx.Locale.Tr "repo.licenses.confirm_delete_package"}}" title="{{ctx.Locale.Tr "repo.licenses.delete_package"}}">
<button class="ui tiny orange button link-action" data-url="{{$.Org.HomeLink}}/-/licenses/packages/{{.ID}}/archive" data-modal-confirm="{{ctx.Locale.Tr "repo.licenses.confirm_archive_package"}}" title="{{ctx.Locale.Tr "repo.licenses.archive_package"}}">
{{svg "octicon-archive" 14}}
</button>
{{if $.CanDelete}}
<button class="ui tiny red button show-modal" data-modal="#license-delete-package-modal" data-modal-form.action="{{$.Org.HomeLink}}/-/licenses/packages/{{.ID}}/delete" title="{{ctx.Locale.Tr "repo.licenses.delete_package"}}">
{{svg "octicon-trash" 14}}
</button>
{{end}}
@@ -131,20 +143,30 @@
{{end}}
</div>
{{if .LicenseKeys}}
{{if or .LicenseKeys .SearchQuery}}
<h4 class="ui top attached header tw-mt-4">
{{svg "octicon-lock" 16}} {{ctx.Locale.Tr "repo.licenses.issued_keys"}}
</h4>
<div class="ui attached segment">
<table class="ui compact table">
<form class="ui form tw-mb-4" method="get" action="{{$.Org.HomeLink}}/-/licenses">
<div class="ui action input tw-w-full">
<input type="text" name="q" value="{{.SearchQuery}}" placeholder="{{ctx.Locale.Tr "repo.licenses.search_placeholder"}}">
<button class="ui primary button" type="submit">{{svg "octicon-search" 14}}</button>
{{if .SearchQuery}}<a class="ui button" href="{{$.Org.HomeLink}}/-/licenses">{{ctx.Locale.Tr "repo.licenses.clear_search"}}</a>{{end}}
</div>
{{if .SearchQuery}}<p class="help tw-mt-2">{{ctx.Locale.Tr "repo.licenses.search_results" (len .LicenseKeys) .SearchQuery}}</p>{{end}}
</form>
{{if .LicenseKeys}}
<table class="ui sortable compact table">
<thead>
<tr>
<th>{{ctx.Locale.Tr "repo.licenses.key_prefix"}}</th>
<th>{{ctx.Locale.Tr "repo.licenses.licensee"}}</th>
<th>{{ctx.Locale.Tr "repo.licenses.domain"}}</th>
<th>{{ctx.Locale.Tr "repo.licenses.expires"}}</th>
<th>{{ctx.Locale.Tr "repo.licenses.last_seen"}}</th>
<th>{{ctx.Locale.Tr "repo.licenses.status"}}</th>
{{if .IsRepoAdmin}}<th></th>{{end}}
{{if .IsRepoAdmin}}<th class="no-sort"></th>{{end}}
</tr>
</thead>
<tbody>
@@ -157,7 +179,8 @@
{{if .IsInternal}} <span class="ui tiny orange label">{{ctx.Locale.Tr "repo.licenses.master_label"}}</span>{{end}}
</div>
</td>
<td>{{.LicenseeName}}{{if .LicenseeEmail}} <small>({{.LicenseeEmail}})</small>{{end}}</td>
<td data-sort-value="{{.LicenseeName}}">{{.LicenseeName}}{{if .LicenseeEmail}} <small>({{.LicenseeEmail}})</small>{{end}}</td>
<td data-sort-value="{{.DomainRestriction}}">{{if .DomainRestriction}}<code>{{.DomainRestriction}}</code>{{else}}<span class="text grey">{{ctx.Locale.Tr "repo.licenses.any_domain"}}</span>{{end}}</td>
<td>{{if eq .ExpiresUnix 0}}{{ctx.Locale.Tr "repo.licenses.never"}}{{else}}{{DateUtils.AbsoluteShort .ExpiresUnix}}{{end}}</td>
<td>{{if eq .LastHeartbeatUnix 0}}{{ctx.Locale.Tr "repo.licenses.never"}}{{else}}{{DateUtils.AbsoluteShort .LastHeartbeatUnix}}{{end}}</td>
<td>{{if .IsActive}}<span class="ui green label">{{ctx.Locale.Tr "repo.licenses.active"}}</span>{{else}}<span class="ui grey label">{{ctx.Locale.Tr "repo.licenses.inactive"}}</span>{{end}}</td>
@@ -174,8 +197,8 @@
<button class="ui tiny red button link-action" data-url="{{$.Org.HomeLink}}/-/licenses/keys/{{.ID}}/revoke" data-modal-confirm="{{ctx.Locale.Tr "repo.licenses.confirm_revoke_key"}}" title="{{ctx.Locale.Tr "repo.licenses.revoke"}}">
{{svg "octicon-x" 14}}
</button>
{{if $.IsSiteAdmin}}
<button class="ui tiny red button link-action" data-url="{{$.Org.HomeLink}}/-/licenses/keys/{{.ID}}/delete" data-modal-confirm="{{ctx.Locale.Tr "repo.licenses.confirm_delete_key"}}" title="{{ctx.Locale.Tr "repo.licenses.delete_key"}}">
{{if $.CanDelete}}
<button class="ui tiny red button show-modal" data-modal="#license-delete-key-modal" data-modal-form.action="{{$.Org.HomeLink}}/-/licenses/keys/{{.ID}}/delete" title="{{ctx.Locale.Tr "repo.licenses.delete_key"}}">
{{svg "octicon-trash" 14}}
</button>
{{end}}
@@ -185,8 +208,80 @@
{{end}}
</tbody>
</table>
{{else if .SearchQuery}}
<p class="tw-text-center text grey">{{ctx.Locale.Tr "repo.licenses.no_search_results"}}</p>
{{end}}
</div>
{{end}}
{{/* Archived Packages */}}
{{if .ArchivedPackages}}
<details class="tw-mt-4">
<summary class="ui grey button">{{svg "octicon-archive" 14}} {{ctx.Locale.Tr "repo.licenses.archived_packages"}} ({{len .ArchivedPackages}})</summary>
<div class="ui segment tw-mt-2">
<table class="ui compact table">
<thead>
<tr>
<th>{{ctx.Locale.Tr "repo.licenses.package_name"}}</th>
<th>{{ctx.Locale.Tr "repo.licenses.keys_issued"}}</th>
{{if .IsRepoAdmin}}<th></th>{{end}}
</tr>
</thead>
<tbody>
{{range .ArchivedPackages}}
<tr>
<td><strong>{{.Name}}</strong>{{if .Description}}<br><small class="text grey">{{.Description}}</small>{{end}}</td>
<td>{{.KeyCount}}</td>
{{if $.IsRepoAdmin}}
<td class="tw-text-right tw-flex tw-gap-1 tw-justify-end">
<button class="ui tiny primary button link-action" data-url="{{$.Org.HomeLink}}/-/licenses/packages/{{.ID}}/unarchive" title="{{ctx.Locale.Tr "repo.licenses.unarchive_package"}}">
{{svg "octicon-reply" 14}}
</button>
{{if $.CanDelete}}
<button class="ui tiny red button show-modal" data-modal="#license-delete-package-modal" data-modal-form.action="{{$.Org.HomeLink}}/-/licenses/packages/{{.ID}}/delete" title="{{ctx.Locale.Tr "repo.licenses.delete_package"}}">
{{svg "octicon-trash" 14}}
</button>
{{end}}
</td>
{{end}}
</tr>
{{end}}
</tbody>
</table>
</div>
</details>
{{end}}
</div>
</div>
<div class="ui small modal" id="license-delete-package-modal">
<div class="header">{{svg "octicon-trash"}} {{ctx.Locale.Tr "repo.licenses.delete_package"}}</div>
<div class="content">
<div class="ui warning message">
<p>{{ctx.Locale.Tr "repo.licenses.confirm_delete_package_typed"}}</p>
</div>
<form class="ui form form-fetch-action" method="post">
{{$.CsrfTokenHtml}}
<div class="required field">
<label>{{ctx.Locale.Tr "repo.licenses.type_name_to_confirm"}}</label>
<input name="confirm_name" required>
</div>
{{template "base/modal_actions_confirm" (dict "ModalButtonDangerText" (ctx.Locale.Tr "repo.licenses.delete_package"))}}
</form>
</div>
</div>
<div class="ui small modal" id="license-delete-key-modal">
<div class="header">{{svg "octicon-trash"}} {{ctx.Locale.Tr "repo.licenses.delete_key"}}</div>
<div class="content">
<div class="ui warning message">
<p>{{ctx.Locale.Tr "repo.licenses.confirm_delete_key_typed"}}</p>
</div>
<form class="ui form form-fetch-action" method="post">
{{$.CsrfTokenHtml}}
{{template "base/modal_actions_confirm" (dict "ModalButtonDangerText" (ctx.Locale.Tr "repo.licenses.delete_key"))}}
</form>
</div>
</div>
{{template "base/footer" .}}
+6 -9
View File
@@ -30,15 +30,12 @@
<p class="help">0 = {{ctx.Locale.Tr "repo.licenses.unlimited"}}</p>
</div>
<div class="field">
<label>{{ctx.Locale.Tr "repo.licenses.channels"}}</label>
{{if .AvailableStreams}}
{{range .AvailableStreams}}
<div class="ui checkbox tw-mr-4 tw-mb-2">
<input name="allowed_channels" type="checkbox" value="{{.Name}}" {{if SliceUtils.Contains $.SelectedChannels .Name}}checked{{end}}>
<label>{{.Name}}{{if .Description}} <small class="text grey">({{.Description}})</small>{{end}}</label>
</div>
{{end}}
{{end}}
<label>{{ctx.Locale.Tr "repo.licenses.domain_lock_hours"}}</label>
<input name="domain_lock_hours" type="number" value="{{.Package.DomainLockHours}}" min="0">
<p class="help">{{ctx.Locale.Tr "repo.licenses.domain_lock_hours_help"}}</p>
</div>
<div class="field">
{{template "shared/combolist" dict "Name" "allowed_channels" "Title" (ctx.Locale.Tr "repo.licenses.channels") "Items" $.ChannelItems "SelectedValues" $.SelectedChannelValues "EmptyText" (ctx.Locale.Tr "repo.licenses.all_channels") "Icon" "octicon-tag"}}
<p class="help">{{ctx.Locale.Tr "repo.licenses.channels_help"}}</p>
</div>
</div>
@@ -27,6 +27,32 @@
<p class="help">{{ctx.Locale.Tr "org.settings.require_key_help"}}</p>
</div>
<div class="field">
<label>{{ctx.Locale.Tr "org.settings.feed_visibility"}}</label>
<select name="feed_visibility" class="ui dropdown">
<option value="public" {{if or (eq .StreamConfig.FeedVisibility "") (eq .StreamConfig.FeedVisibility "public")}}selected{{end}}>{{ctx.Locale.Tr "org.settings.feed_visibility_public"}}</option>
<option value="no-download" {{if eq .StreamConfig.FeedVisibility "no-download"}}selected{{end}}>{{ctx.Locale.Tr "org.settings.feed_visibility_no_download"}}</option>
<option value="hidden" {{if eq .StreamConfig.FeedVisibility "hidden"}}selected{{end}}>{{ctx.Locale.Tr "org.settings.feed_visibility_hidden"}}</option>
</select>
<p class="help">{{ctx.Locale.Tr "org.settings.feed_visibility_help"}}</p>
</div>
<div class="field">
<label>{{ctx.Locale.Tr "org.settings.download_gating"}}</label>
<select name="download_gating" class="ui dropdown">
<option value="none" {{if or (eq .StreamConfig.DownloadGating "") (eq .StreamConfig.DownloadGating "none")}}selected{{end}}>{{ctx.Locale.Tr "org.settings.download_gating_none"}}</option>
<option value="prerelease" {{if eq .StreamConfig.DownloadGating "prerelease"}}selected{{end}}>{{ctx.Locale.Tr "org.settings.download_gating_prerelease"}}</option>
<option value="all" {{if eq .StreamConfig.DownloadGating "all"}}selected{{end}}>{{ctx.Locale.Tr "org.settings.download_gating_all"}}</option>
</select>
<p class="help">{{ctx.Locale.Tr "org.settings.download_gating_help"}}</p>
</div>
<div class="field">
<label>{{ctx.Locale.Tr "org.settings.support_url"}}</label>
<input name="support_url" value="{{.StreamConfig.SupportURL}}" placeholder="https://mokoconsulting.tech/support">
<p class="help">{{ctx.Locale.Tr "org.settings.support_url_help"}}</p>
</div>
<div class="ui divider"></div>
{{/* Section 2: Extension Metadata */}}
+133 -20
View File
@@ -66,8 +66,11 @@
<a class="ui tiny button" href="{{$.RepoLink}}/licenses/packages/{{.ID}}/edit" title="{{ctx.Locale.Tr "repo.licenses.edit_package"}}">
{{svg "octicon-pencil" 14}}
</a>
{{if $.IsSiteAdmin}}
<button class="ui tiny red button link-action" data-url="{{$.RepoLink}}/licenses/packages/{{.ID}}/delete" data-modal-confirm="{{ctx.Locale.Tr "repo.licenses.confirm_delete_package"}}" title="{{ctx.Locale.Tr "repo.licenses.delete_package"}}">
<button class="ui tiny orange button link-action" data-url="{{$.RepoLink}}/licenses/packages/{{.ID}}/archive" data-modal-confirm="{{ctx.Locale.Tr "repo.licenses.confirm_archive_package"}}" title="{{ctx.Locale.Tr "repo.licenses.archive_package"}}">
{{svg "octicon-archive" 14}}
</button>
{{if $.CanDelete}}
<button class="ui tiny red button show-modal" data-modal="#license-delete-package-modal" data-modal-form.action="{{$.RepoLink}}/licenses/packages/{{.ID}}/delete" title="{{ctx.Locale.Tr "repo.licenses.delete_package"}}">
{{svg "octicon-trash" 14}}
</button>
{{end}}
@@ -105,30 +108,39 @@
<input name="description" placeholder="e.g. Annual pro subscription with all channels">
</div>
</div>
<div class="three fields">
<div class="field">
<div class="fields">
<div class="four wide field">
<label>{{ctx.Locale.Tr "repo.licenses.duration"}} ({{ctx.Locale.Tr "repo.licenses.days"}})</label>
<input name="duration_days" type="number" value="0" min="0">
<p class="help">0 = {{ctx.Locale.Tr "repo.licenses.lifetime"}}</p>
</div>
<div class="field">
<div class="four wide field">
<label>{{ctx.Locale.Tr "repo.licenses.max_sites"}}</label>
<input name="max_sites" type="number" value="0" min="0">
<p class="help">0 = {{ctx.Locale.Tr "repo.licenses.unlimited"}}</p>
</div>
<div class="four wide field">
<label>{{ctx.Locale.Tr "repo.licenses.domain_lock_hours"}}</label>
<input name="domain_lock_hours" type="number" value="0" min="0">
<p class="help">{{ctx.Locale.Tr "repo.licenses.domain_lock_hours_help"}}</p>
</div>
<div class="field">
<label>{{ctx.Locale.Tr "repo.licenses.channels"}}</label>
{{if .AvailableStreams}}
{{range .AvailableStreams}}
<div class="ui checkbox tw-mr-4 tw-mb-2">
<input name="allowed_channels" type="checkbox" value="{{.Name}}">
<label>{{.Name}}{{if .Description}} <small class="text grey">({{.Description}})</small>{{end}}</label>
</div>
{{end}}
{{end}}
{{template "shared/combolist" dict "Name" "allowed_channels" "Title" (ctx.Locale.Tr "repo.licenses.channels") "Items" $.ChannelItems "SelectedValues" "" "EmptyText" (ctx.Locale.Tr "repo.licenses.all_channels") "Icon" "octicon-tag"}}
<p class="help">{{ctx.Locale.Tr "repo.licenses.channels_help"}}</p>
</div>
</div>
<div class="field">
<label>{{ctx.Locale.Tr "repo.licenses.repo_scope"}}</label>
<select name="repo_scope" class="ui dropdown">
<option value="all">{{ctx.Locale.Tr "repo.licenses.repo_scope_all"}}</option>
{{if .OrgRepos}}
{{range .OrgRepos}}
<option value="{{.ID}}">{{.Name}}</option>
{{end}}
{{end}}
</select>
<p class="help">{{ctx.Locale.Tr "repo.licenses.repo_scope_help"}}</p>
</div>
<button class="ui primary button" type="submit">{{ctx.Locale.Tr "repo.licenses.create_package"}}</button>
</form>
</div>
@@ -137,20 +149,30 @@
{{end}}
{{/* Issued Keys */}}
{{if .LicenseKeys}}
{{if or .LicenseKeys .SearchQuery}}
<h4 class="ui top attached header tw-mt-4">
{{svg "octicon-lock" 16}} {{ctx.Locale.Tr "repo.licenses.issued_keys"}}
</h4>
<div class="ui attached segment">
<table class="ui compact table">
<form class="ui form tw-mb-4" method="get" action="{{.RepoLink}}/licenses">
<div class="ui action input tw-w-full">
<input type="text" name="q" value="{{.SearchQuery}}" placeholder="{{ctx.Locale.Tr "repo.licenses.search_placeholder"}}">
<button class="ui primary button" type="submit">{{svg "octicon-search" 14}}</button>
{{if .SearchQuery}}<a class="ui button" href="{{.RepoLink}}/licenses">{{ctx.Locale.Tr "repo.licenses.clear_search"}}</a>{{end}}
</div>
{{if .SearchQuery}}<p class="help tw-mt-2">{{ctx.Locale.Tr "repo.licenses.search_results" (len .LicenseKeys) .SearchQuery}}</p>{{end}}
</form>
{{if .LicenseKeys}}
<table class="ui sortable compact table">
<thead>
<tr>
<th>{{ctx.Locale.Tr "repo.licenses.key_prefix"}}</th>
<th>{{ctx.Locale.Tr "repo.licenses.licensee"}}</th>
<th>{{ctx.Locale.Tr "repo.licenses.domain"}}</th>
<th>{{ctx.Locale.Tr "repo.licenses.expires"}}</th>
<th>{{ctx.Locale.Tr "repo.licenses.last_seen"}}</th>
<th>{{ctx.Locale.Tr "repo.licenses.status"}}</th>
{{if .IsRepoAdmin}}<th></th>{{end}}
{{if .IsRepoAdmin}}<th class="no-sort"></th>{{end}}
</tr>
</thead>
<tbody>
@@ -163,7 +185,8 @@
{{if .IsInternal}} <span class="ui tiny orange label">{{ctx.Locale.Tr "repo.licenses.master_label"}}</span>{{end}}
</div>
</td>
<td>{{.LicenseeName}}{{if .LicenseeEmail}} <small>({{.LicenseeEmail}})</small>{{end}}</td>
<td data-sort-value="{{.LicenseeName}}">{{.LicenseeName}}{{if .LicenseeEmail}} <small>({{.LicenseeEmail}})</small>{{end}}</td>
<td data-sort-value="{{.DomainRestriction}}">{{if .DomainRestriction}}<code>{{.DomainRestriction}}</code>{{else}}<span class="text grey">{{ctx.Locale.Tr "repo.licenses.any_domain"}}</span>{{end}}</td>
<td>{{if eq .ExpiresUnix 0}}{{ctx.Locale.Tr "repo.licenses.never"}}{{else}}{{DateUtils.AbsoluteShort .ExpiresUnix}}{{end}}</td>
<td>{{if eq .LastHeartbeatUnix 0}}{{ctx.Locale.Tr "repo.licenses.never"}}{{else}}{{DateUtils.AbsoluteShort .LastHeartbeatUnix}}{{end}}</td>
<td>{{if .IsActive}}<span class="ui green label">{{ctx.Locale.Tr "repo.licenses.active"}}</span>{{else}}<span class="ui grey label">{{ctx.Locale.Tr "repo.licenses.inactive"}}</span>{{end}}</td>
@@ -180,8 +203,8 @@
<button class="ui tiny red button link-action" data-url="{{$.RepoLink}}/licenses/keys/{{.ID}}/revoke" data-modal-confirm="{{ctx.Locale.Tr "repo.licenses.confirm_revoke_key"}}" title="{{ctx.Locale.Tr "repo.licenses.revoke"}}">
{{svg "octicon-x" 14}}
</button>
{{if $.IsSiteAdmin}}
<button class="ui tiny red button link-action" data-url="{{$.RepoLink}}/licenses/keys/{{.ID}}/delete" data-modal-confirm="{{ctx.Locale.Tr "repo.licenses.confirm_delete_key"}}" title="{{ctx.Locale.Tr "repo.licenses.delete_key"}}">
{{if $.CanDelete}}
<button class="ui tiny red button show-modal" data-modal="#license-delete-key-modal" data-modal-form.action="{{$.RepoLink}}/licenses/keys/{{.ID}}/delete" title="{{ctx.Locale.Tr "repo.licenses.delete_key"}}">
{{svg "octicon-trash" 14}}
</button>
{{end}}
@@ -191,9 +214,50 @@
{{end}}
</tbody>
</table>
{{else if .SearchQuery}}
<p class="tw-text-center text grey">{{ctx.Locale.Tr "repo.licenses.no_search_results"}}</p>
{{end}}
</div>
{{end}}
{{/* Archived Packages */}}
{{if .ArchivedPackages}}
<details class="tw-mt-4">
<summary class="ui grey button">{{svg "octicon-archive" 14}} {{ctx.Locale.Tr "repo.licenses.archived_packages"}} ({{len .ArchivedPackages}})</summary>
<div class="ui segment tw-mt-2">
<table class="ui compact table">
<thead>
<tr>
<th>{{ctx.Locale.Tr "repo.licenses.package_name"}}</th>
<th>{{ctx.Locale.Tr "repo.licenses.keys_issued"}}</th>
{{if .IsRepoAdmin}}<th></th>{{end}}
</tr>
</thead>
<tbody>
{{range .ArchivedPackages}}
<tr>
<td><strong>{{.Name}}</strong>{{if .Description}}<br><small class="text grey">{{.Description}}</small>{{end}}</td>
<td>{{.KeyCount}}</td>
{{if $.IsRepoAdmin}}
<td class="tw-text-right tw-flex tw-gap-1 tw-justify-end">
<button class="ui tiny primary button link-action" data-url="{{$.RepoLink}}/licenses/packages/{{.ID}}/unarchive" title="{{ctx.Locale.Tr "repo.licenses.unarchive_package"}}">
{{svg "octicon-reply" 14}}
</button>
{{if $.CanDelete}}
<button class="ui tiny red button show-modal" data-modal="#license-delete-package-modal" data-modal-form.action="{{$.RepoLink}}/licenses/packages/{{.ID}}/delete" title="{{ctx.Locale.Tr "repo.licenses.delete_package"}}">
{{svg "octicon-trash" 14}}
</button>
{{end}}
</td>
{{end}}
</tr>
{{end}}
</tbody>
</table>
</div>
</details>
{{end}}
{{/* Update Feed URLs */}}
{{if .LicensingEnabled}}
<h4 class="ui top attached header tw-mt-4">
@@ -218,8 +282,57 @@
</div>
</div>
{{end}}
{{if eq .RepoUpdatePlatform "wordpress"}}
<div class="field tw-mt-2">
<label>{{ctx.Locale.Tr "repo.licenses.feed_wordpress_updates"}}</label>
<div class="ui action input tw-w-full">
<input class="js-feed-url-wordpress" type="text" readonly value="{{.Repository.HTMLURL ctx}}/updates/wordpress.json" onclick="this.select()">
<button class="ui button" data-clipboard-target=".js-feed-url-wordpress" data-tooltip-content="{{ctx.Locale.Tr "copy_url"}}">{{svg "octicon-copy" 14}}</button>
</div>
</div>
{{end}}
<div class="field tw-mt-2">
<label>{{ctx.Locale.Tr "repo.licenses.feed_changelog_xml"}}</label>
<div class="ui action input tw-w-full">
<input class="js-feed-url-changelog" type="text" readonly value="{{.Repository.HTMLURL ctx}}/changelog.xml" onclick="this.select()">
<button class="ui button" data-clipboard-target=".js-feed-url-changelog" data-tooltip-content="{{ctx.Locale.Tr "copy_url"}}">{{svg "octicon-copy" 14}}</button>
</div>
</div>
</div>
{{end}}
</div>
</div>
{{/* Delete Package Confirmation Modal */}}
<div class="ui small modal" id="license-delete-package-modal">
<div class="header">{{svg "octicon-trash"}} {{ctx.Locale.Tr "repo.licenses.delete_package"}}</div>
<div class="content">
<div class="ui warning message">
<p>{{ctx.Locale.Tr "repo.licenses.confirm_delete_package_typed"}}</p>
</div>
<form class="ui form form-fetch-action" method="post">
{{$.CsrfTokenHtml}}
<div class="required field">
<label>{{ctx.Locale.Tr "repo.licenses.type_name_to_confirm"}}</label>
<input name="confirm_name" required>
</div>
{{template "base/modal_actions_confirm" (dict "ModalButtonDangerText" (ctx.Locale.Tr "repo.licenses.delete_package"))}}
</form>
</div>
</div>
{{/* Delete Key Confirmation Modal */}}
<div class="ui small modal" id="license-delete-key-modal">
<div class="header">{{svg "octicon-trash"}} {{ctx.Locale.Tr "repo.licenses.delete_key"}}</div>
<div class="content">
<div class="ui warning message">
<p>{{ctx.Locale.Tr "repo.licenses.confirm_delete_key_typed"}}</p>
</div>
<form class="ui form form-fetch-action" method="post">
{{$.CsrfTokenHtml}}
{{template "base/modal_actions_confirm" (dict "ModalButtonDangerText" (ctx.Locale.Tr "repo.licenses.delete_key"))}}
</form>
</div>
</div>
{{template "base/footer" .}}
+6 -9
View File
@@ -30,15 +30,12 @@
<p class="help">0 = {{ctx.Locale.Tr "repo.licenses.unlimited"}}</p>
</div>
<div class="field">
<label>{{ctx.Locale.Tr "repo.licenses.channels"}}</label>
{{if .AvailableStreams}}
{{range .AvailableStreams}}
<div class="ui checkbox tw-mr-4 tw-mb-2">
<input name="allowed_channels" type="checkbox" value="{{.Name}}" {{if SliceUtils.Contains $.SelectedChannels .Name}}checked{{end}}>
<label>{{.Name}}{{if .Description}} <small class="text grey">({{.Description}})</small>{{end}}</label>
</div>
{{end}}
{{end}}
<label>{{ctx.Locale.Tr "repo.licenses.domain_lock_hours"}}</label>
<input name="domain_lock_hours" type="number" value="{{.Package.DomainLockHours}}" min="0">
<p class="help">{{ctx.Locale.Tr "repo.licenses.domain_lock_hours_help"}}</p>
</div>
<div class="field">
{{template "shared/combolist" dict "Name" "allowed_channels" "Title" (ctx.Locale.Tr "repo.licenses.channels") "Items" $.ChannelItems "SelectedValues" $.SelectedChannelValues "EmptyText" (ctx.Locale.Tr "repo.licenses.all_channels") "Icon" "octicon-tag"}}
<p class="help">{{ctx.Locale.Tr "repo.licenses.channels_help"}}</p>
</div>
</div>
+6
View File
@@ -73,6 +73,11 @@
{{$release.RenderedNote}}
</div>
<div class="divider"></div>
{{if $.HideReleaseDownloads}}
<div class="ui message">
<p>{{ctx.Locale.Tr "repo.release.downloads_require_login"}}</p>
</div>
{{else}}
<details class="download" {{if eq $idx 0}}open{{end}}>
<summary>
{{ctx.Locale.Tr "repo.release.downloads"}}
@@ -107,6 +112,7 @@
{{end}}
</ul>
</details>
{{end}}{{/* end HideReleaseDownloads */}}
</div>
</li>
{{end}}
+60
View File
@@ -544,6 +544,66 @@
</div>
<p class="help">{{ctx.Locale.Tr "repo.settings.require_update_key_help"}}</p>
</div>
<div class="inline field">
<label>{{ctx.Locale.Tr "repo.settings.download_gating"}}</label>
<select name="download_gating" class="ui dropdown">
<option value="none" {{if or (not .RepoUpdateConfig) (eq .RepoUpdateConfig.DownloadGating "") (eq .RepoUpdateConfig.DownloadGating "none")}}selected{{end}}>{{ctx.Locale.Tr "org.settings.download_gating_none"}}</option>
<option value="prerelease" {{if and .RepoUpdateConfig (eq .RepoUpdateConfig.DownloadGating "prerelease")}}selected{{end}}>{{ctx.Locale.Tr "org.settings.download_gating_prerelease"}}</option>
<option value="all" {{if and .RepoUpdateConfig (eq .RepoUpdateConfig.DownloadGating "all")}}selected{{end}}>{{ctx.Locale.Tr "org.settings.download_gating_all"}}</option>
</select>
<p class="help">{{ctx.Locale.Tr "org.settings.download_gating_help"}}</p>
</div>
<div class="inline field">
<label>{{ctx.Locale.Tr "repo.settings.support_url"}}</label>
<input name="support_url" value="{{if .RepoUpdateConfig}}{{.RepoUpdateConfig.SupportURL}}{{end}}" placeholder="https://mokoconsulting.tech/support">
<p class="help">{{ctx.Locale.Tr "repo.settings.support_url_help"}}</p>
</div>
<div class="ui divider"></div>
<h6>{{ctx.Locale.Tr "repo.settings.extension_metadata"}}</h6>
<p class="help tw-mb-4">{{ctx.Locale.Tr "repo.settings.extension_metadata_desc"}}</p>
<div class="two fields">
<div class="field">
<label>{{ctx.Locale.Tr "org.settings.extension_name"}}</label>
<input name="extension_name" value="{{if .RepoUpdateConfig}}{{.RepoUpdateConfig.ExtensionName}}{{end}}" placeholder="pkg_myextension">
<p class="help">{{ctx.Locale.Tr "org.settings.extension_name_help"}}</p>
</div>
<div class="field">
<label>{{ctx.Locale.Tr "org.settings.display_name"}}</label>
<input name="display_name" value="{{if .RepoUpdateConfig}}{{.RepoUpdateConfig.DisplayName}}{{end}}" placeholder="Package - My Extension">
<p class="help">{{ctx.Locale.Tr "org.settings.display_name_help"}}</p>
</div>
</div>
<div class="two fields">
<div class="field">
<label>{{ctx.Locale.Tr "org.settings.extension_type"}}</label>
<select name="extension_type" class="ui dropdown">
<option value="">{{ctx.Locale.Tr "repo.settings.inherit_org"}}</option>
<option value="package" {{if and .RepoUpdateConfig (eq .RepoUpdateConfig.ExtensionType "package")}}selected{{end}}>Package</option>
<option value="component" {{if and .RepoUpdateConfig (eq .RepoUpdateConfig.ExtensionType "component")}}selected{{end}}>Component</option>
<option value="module" {{if and .RepoUpdateConfig (eq .RepoUpdateConfig.ExtensionType "module")}}selected{{end}}>Module</option>
<option value="plugin" {{if and .RepoUpdateConfig (eq .RepoUpdateConfig.ExtensionType "plugin")}}selected{{end}}>Plugin</option>
<option value="template" {{if and .RepoUpdateConfig (eq .RepoUpdateConfig.ExtensionType "template")}}selected{{end}}>Template</option>
<option value="library" {{if and .RepoUpdateConfig (eq .RepoUpdateConfig.ExtensionType "library")}}selected{{end}}>Library</option>
</select>
</div>
<div class="field">
<label>{{ctx.Locale.Tr "org.settings.target_version"}}</label>
<input name="target_version" value="{{if .RepoUpdateConfig}}{{.RepoUpdateConfig.TargetVersion}}{{end}}" placeholder="(5|6)\..*">
<p class="help">{{ctx.Locale.Tr "org.settings.target_version_help"}}</p>
</div>
</div>
<div class="two fields">
<div class="field">
<label>{{ctx.Locale.Tr "org.settings.maintainer"}}</label>
<input name="maintainer" value="{{if .RepoUpdateConfig}}{{.RepoUpdateConfig.Maintainer}}{{end}}" placeholder="Moko Consulting">
</div>
<div class="field">
<label>{{ctx.Locale.Tr "org.settings.php_minimum"}}</label>
<input name="php_minimum" value="{{if .RepoUpdateConfig}}{{.RepoUpdateConfig.PHPMinimum}}{{end}}" placeholder="8.1">
</div>
</div>
</div>
{{$isPackagesEnabled := .Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypePackages}}
+194
View File
@@ -0,0 +1,194 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package integration
import (
"fmt"
"net/http"
"testing"
auth_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/auth"
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/unittest"
user_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/user"
api "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/structs"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/tests"
"github.com/stretchr/testify/assert"
)
func TestAPILicensePackages(t *testing.T) {
defer tests.PrepareTestEnv(t)()
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
token := getUserToken(t, user.LowerName, auth_model.AccessTokenScopeWriteRepository)
urlPrefix := fmt.Sprintf("/api/v1/repos/%s/%s", user.Name, repo.Name)
t.Run("ListPackages", func(t *testing.T) {
req := NewRequest(t, "GET", urlPrefix+"/license-packages").AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var packages []*api.LicensePackage
DecodeJSON(t, resp, &packages)
// Initially empty (master may be auto-created on web visit, but not via API list).
})
t.Run("CreatePackage", func(t *testing.T) {
body := `{"name":"Test Pro Annual","description":"Annual pro subscription","duration_days":365,"max_sites":5}`
req := NewRequestWithBody(t, "POST", urlPrefix+"/license-packages", []byte(body)).
AddTokenAuth(token).
SetHeader("Content-Type", "application/json")
resp := MakeRequest(t, req, http.StatusCreated)
var pkg api.LicensePackage
DecodeJSON(t, resp, &pkg)
assert.Equal(t, "Test Pro Annual", pkg.Name)
assert.Equal(t, 365, pkg.DurationDays)
assert.Equal(t, 5, pkg.MaxSites)
assert.True(t, pkg.IsActive)
assert.True(t, pkg.ID > 0)
})
t.Run("CreatePackageNoName", func(t *testing.T) {
body := `{"description":"Missing name"}`
req := NewRequestWithBody(t, "POST", urlPrefix+"/license-packages", []byte(body)).
AddTokenAuth(token).
SetHeader("Content-Type", "application/json")
MakeRequest(t, req, http.StatusUnprocessableEntity)
})
}
func TestAPILicenseKeys(t *testing.T) {
defer tests.PrepareTestEnv(t)()
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
token := getUserToken(t, user.LowerName, auth_model.AccessTokenScopeWriteRepository)
urlPrefix := fmt.Sprintf("/api/v1/repos/%s/%s", user.Name, repo.Name)
// Create a package first.
body := `{"name":"Test Package","duration_days":30}`
req := NewRequestWithBody(t, "POST", urlPrefix+"/license-packages", []byte(body)).
AddTokenAuth(token).
SetHeader("Content-Type", "application/json")
resp := MakeRequest(t, req, http.StatusCreated)
var pkg api.LicensePackage
DecodeJSON(t, resp, &pkg)
var createdKeyID int64
var rawKey string
t.Run("CreateKey", func(t *testing.T) {
body := fmt.Sprintf(`{"package_id":%d,"licensee_name":"John Doe","licensee_email":"john@example.com"}`, pkg.ID)
req := NewRequestWithBody(t, "POST", urlPrefix+"/license-keys", []byte(body)).
AddTokenAuth(token).
SetHeader("Content-Type", "application/json")
resp := MakeRequest(t, req, http.StatusCreated)
var key api.LicenseKeyCreated
DecodeJSON(t, resp, &key)
assert.NotEmpty(t, key.RawKey)
assert.Contains(t, key.RawKey, "MOKO-")
assert.Equal(t, "John Doe", key.LicenseeName)
assert.True(t, key.IsActive)
createdKeyID = key.ID
rawKey = key.RawKey
})
t.Run("ListKeys", func(t *testing.T) {
req := NewRequest(t, "GET", urlPrefix+"/license-keys").AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var keys []*api.LicenseKey
DecodeJSON(t, resp, &keys)
assert.GreaterOrEqual(t, len(keys), 1)
})
t.Run("EditKey", func(t *testing.T) {
body := `{"licensee_name":"Jane Doe","domain_restriction":"example.com,test.com"}`
req := NewRequestWithBody(t, "PATCH", fmt.Sprintf("%s/license-keys/%d", urlPrefix, createdKeyID), []byte(body)).
AddTokenAuth(token).
SetHeader("Content-Type", "application/json")
resp := MakeRequest(t, req, http.StatusOK)
var key api.LicenseKey
DecodeJSON(t, resp, &key)
assert.Equal(t, "Jane Doe", key.LicenseeName)
})
t.Run("RenewKey", func(t *testing.T) {
req := NewRequest(t, "POST", fmt.Sprintf("%s/license-keys/%d/renew", urlPrefix, createdKeyID)).
AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var key api.LicenseKey
DecodeJSON(t, resp, &key)
assert.NotNil(t, key.ExpiresAt)
})
t.Run("ValidateKey", func(t *testing.T) {
body := fmt.Sprintf(`{"key":"%s","domain":"example.com"}`, rawKey)
req := NewRequestWithBody(t, "POST", urlPrefix+"/license-keys/validate", []byte(body)).
SetHeader("Content-Type", "application/json")
// Note: no token — this is a public endpoint.
resp := MakeRequest(t, req, http.StatusOK)
var result api.ValidateLicenseKeyResponse
DecodeJSON(t, resp, &result)
assert.True(t, result.Valid)
assert.Equal(t, "Test Package", result.PackageName)
})
t.Run("ValidateInvalidKey", func(t *testing.T) {
body := `{"key":"MOKO-XXXX-XXXX-XXXX-XXXX","domain":"example.com"}`
req := NewRequestWithBody(t, "POST", urlPrefix+"/license-keys/validate", []byte(body)).
SetHeader("Content-Type", "application/json")
resp := MakeRequest(t, req, http.StatusOK)
var result api.ValidateLicenseKeyResponse
DecodeJSON(t, resp, &result)
assert.False(t, result.Valid)
})
t.Run("DeleteKey", func(t *testing.T) {
req := NewRequest(t, "DELETE", fmt.Sprintf("%s/license-keys/%d", urlPrefix, createdKeyID)).
AddTokenAuth(token)
MakeRequest(t, req, http.StatusNoContent)
})
}
func TestAPILicensePurchaseWebhook(t *testing.T) {
defer tests.PrepareTestEnv(t)()
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
token := getUserToken(t, user.LowerName, auth_model.AccessTokenScopeWriteRepository)
urlPrefix := fmt.Sprintf("/api/v1/repos/%s/%s", user.Name, repo.Name)
// Create a package.
body := `{"name":"Purchase Test","duration_days":90}`
req := NewRequestWithBody(t, "POST", urlPrefix+"/license-packages", []byte(body)).
AddTokenAuth(token).
SetHeader("Content-Type", "application/json")
resp := MakeRequest(t, req, http.StatusCreated)
var pkg api.LicensePackage
DecodeJSON(t, resp, &pkg)
t.Run("PurchaseNewKey", func(t *testing.T) {
body := fmt.Sprintf(`{"package_id":%d,"licensee_name":"Buyer","licensee_email":"buyer@shop.com","domain":"shop.com","payment_ref":"stripe_pi_test123"}`, pkg.ID)
req := NewRequestWithBody(t, "POST", urlPrefix+"/license-keys/purchase", []byte(body)).
AddTokenAuth(token).
SetHeader("Content-Type", "application/json")
resp := MakeRequest(t, req, http.StatusCreated)
var key api.LicenseKeyCreated
DecodeJSON(t, resp, &key)
assert.NotEmpty(t, key.RawKey)
assert.Equal(t, "Buyer", key.LicenseeName)
})
t.Run("PurchaseIdempotent", func(t *testing.T) {
// Same payment_ref should return existing key without raw_key.
body := fmt.Sprintf(`{"package_id":%d,"licensee_name":"Buyer","payment_ref":"stripe_pi_test123"}`, pkg.ID)
req := NewRequestWithBody(t, "POST", urlPrefix+"/license-keys/purchase", []byte(body)).
AddTokenAuth(token).
SetHeader("Content-Type", "application/json")
resp := MakeRequest(t, req, http.StatusOK)
var key api.LicenseKeyCreated
DecodeJSON(t, resp, &key)
assert.Empty(t, key.RawKey) // Raw key not available on idempotent return.
})
}
+446
View File
@@ -0,0 +1,446 @@
# License Management System
MokoGitea includes a built-in commercial license management system for organizations that sell extensions (Joomla, Dolibarr, etc.). It handles license key generation, validation, update feed gating, domain enforcement, and payment integration.
## Overview
The license system operates at two levels:
- **Organization-level**: Manages packages and keys shared across all repos in an org
- **Repository-level**: Same features scoped to a single repo, plus update feed URLs
## Core Concepts
### License Packages
A package defines a subscription tier (e.g., "Pro Annual", "Basic Monthly"). Each package specifies:
| Field | Description |
|---|---|
| **Name** | Human-readable name (e.g., "Pro Annual") |
| **Duration** | Key validity in days. 0 = lifetime |
| **Max Sites** | Maximum unique domains. 0 = unlimited |
| **Channels** | Which update streams this package grants access to |
| **Active** | Whether the package is enabled |
### License Keys
Keys are generated from packages. Format: `MOKO-XXXX-XXXX-XXXX-XXXX`.
**Important**: Keys are hashed (SHA-256) before storage. The full key is only shown **once** at creation time. Store it securely.
Each key can have:
- **Licensee name/email** for customer tracking
- **Domain restriction** (comma-separated allowed domains)
- **Max sites override** (0 = use package default)
- **Custom expiry date**
### Master Package
Every org/repo automatically gets a "Master (Internal)" package with an internal key. This provides unlimited access to all channels and cannot be edited or deleted. Master keys can only be revoked.
## Web UI
### Repository Licenses Page
`/{owner}/{repo}/licenses`
- View all packages and issued keys
- Create new packages with channel selection
- Generate keys from packages (auto-generated or custom key for admins)
- Renew expired keys (sync icon, extends by package duration)
- Edit packages and keys (pencil icon)
- Revoke keys (X icon, with confirmation modal)
- Delete packages (trash icon, site admins only, with confirmation modal)
- Copy update feed URLs (clipboard button with tooltip feedback)
- Copy newly created keys (clipboard button in success message)
### Organization Licenses Page
`/{org}/-/licenses`
Same features as repo level, scoped to the organization. Packages and keys created here are shared across all repos in the org.
## API Endpoints
All endpoints are under `/api/v1/repos/{owner}/{repo}/`.
### Authenticated (requires token + repo admin)
| Method | Path | Description |
|---|---|---|
| GET | `/license-packages` | List all packages |
| POST | `/license-packages` | Create a package |
| GET | `/license-keys` | List all keys |
| POST | `/license-keys` | Create a key (returns full key) |
| PATCH | `/license-keys/{id}` | Edit a key |
| DELETE | `/license-keys/{id}` | Delete a key |
| GET | `/license-keys/{id}/usage` | Get usage logs (last 100) |
| POST | `/license-keys/purchase` | Create key from payment webhook |
### Public (no auth required)
| Method | Path | Description |
|---|---|---|
| POST | `/license-keys/validate` | Validate a key + domain |
### Purchase Webhook
`POST /api/v1/repos/{owner}/{repo}/license-keys/purchase`
Designed for payment system integration (Stripe, PayPal, etc.):
```json
{
"package_id": 1,
"licensee_name": "John Doe",
"licensee_email": "john@example.com",
"domain": "example.com",
"payment_ref": "stripe_pi_xxx"
}
```
The `payment_ref` field provides **idempotency** -- if a key already exists with that reference, the existing key is returned instead of creating a duplicate.
Response includes the full `raw_key` (only on first creation).
### Validation Endpoint
`POST /api/v1/repos/{owner}/{repo}/license-keys/validate`
For client-side license checks:
```json
{
"key": "MOKO-XXXX-XXXX-XXXX-XXXX",
"domain": "example.com"
}
```
Response:
```json
{
"valid": true,
"package_name": "Pro Annual",
"channels": "[\"stable\",\"release-candidate\"]",
"expires_at": "2027-05-30T00:00:00Z",
"sites_used": 2,
"max_sites": 5
}
```
## Update Feed Integration
### Joomla (updates.xml)
`GET /{owner}/{repo}/updates.xml`
When `RequireKey` is enabled for a repo, the XML feed includes a `<downloadkey>` element:
```xml
<update>
<name>MokoOnyx</name>
<version>3.10.9</version>
<downloads>
<downloadurl type="full" format="zip">https://git.mokoconsulting.tech/.../mokoonyx.zip</downloadurl>
</downloads>
<downloadkey prefix="&amp;dlid=" suffix=""/>
</update>
```
This tells Joomla to show a **Download Key** field in System > Update Sites. The key is sent as `&dlid=MOKO-XXXX-...` on download requests.
The server accepts keys via three query parameters: `key`, `download_key`, or `dlid`.
### Dolibarr (dolibarr.json)
`GET /{owner}/{repo}/updates/dolibarr.json`
Uses the same license key validation as the Joomla XML feed. All platforms share the same licensing system. Invalid or missing keys receive an empty response.
## Domain Enforcement
When a key has a `DomainRestriction` set (comma-separated domains):
1. The domain from the request is checked against the allowed list
2. Comparison is case-insensitive
3. Master/internal keys bypass domain checks
### Auto-Association (Lock-on-First-Use)
When a key has **no** `DomainRestriction` set and a domain is provided during validation:
1. The domain is automatically appended to the key's `DomainRestriction`
2. On subsequent heartbeats, additional domains can be added (up to `MaxSites`)
3. Once `MaxSites` is reached, no new domains are accepted
4. This provides zero-config domain locking — the first site to use a key "claims" it
### Site Limit Enforcement
When `MaxSites` is set (on key or package):
1. The system counts unique domains from usage records
2. If the requesting domain is already known, it's allowed through
3. If the domain is new and the limit is reached, the request is rejected
4. Error: `site limit reached (N/N)`
5. Auto-association also respects this limit — domains won't be added past the cap
## Channel System
Channels control which update streams a license key grants access to. Available channels come from the org's Update Stream configuration:
**Default Joomla streams**: stable, release-candidate, beta, alpha, development
Channels are selected via checkboxes when creating/editing a package. They're stored as a JSON array (e.g., `["stable","release-candidate"]`).
A package with no channels selected grants access to **all channels**.
## Enabling the Licensing System
Licensing is **off by default** and must be enabled explicitly.
### Organization Level
Go to **Organization Settings > Licenses & Update Streams** and check:
- **Enable licensing system** -- shows the Licenses tab for all org members
- **Require license key for update feeds** -- gates update XML/JSON feeds behind key validation
### Repository Level
Go to **Repository Settings > Advanced** and check:
- **Enable licensing system** -- shows the Licenses tab for this repo
- **Require license key for update feed access** -- gates this repo's update feeds
Either org-level or repo-level enabling is sufficient to show the Licenses tab.
### Feed Visibility
Controls what unauthenticated users see when accessing update feed URLs:
| Mode | Update Feed | Release Page | Download Files |
|---|---|---|---|
| **Public** | Full feed with download URLs | Public | Public |
| **No-download** | Version info only (URLs stripped) | Public (notes visible) | "Sign in to download" |
| **Hidden** | Empty feed | Redirects to login | Login required |
The **no-download** mode is ideal for commercial extensions: customers see that updates exist (motivating purchase/renewal) but cannot download without signing in. Release notes and changelogs remain publicly readable.
## Key Heartbeat
Every time a license key is successfully validated (via update feed or API), the `LastHeartbeatUnix` timestamp is updated. This is shown as "Last Seen" in the keys table and included in API responses as `last_heartbeat`.
Use this to identify inactive keys or monitor customer usage patterns.
## Release Tag Enforcement
When licensing is enabled, release tags with prerelease suffixes (e.g., `-rc`, `-beta`, `-alpha`, `-dev`) must match a configured update stream suffix. This ensures the update feed generator can correctly categorize releases.
- Tags without prerelease suffixes (e.g., `v1.0.0`) are always allowed (stable stream)
- Tags with suffixes must match a stream (e.g., `v1.0.0-rc1` matches the `release-candidate` stream with suffix `-rc`)
- When licensing is disabled, any tag format is accepted
## Permissions
Licenses use Gitea's **unit permission system** (`TypeLicenses`). Teams can be granted Read, Write, or Admin access.
| Action | Required Permission |
|---|---|
| View licenses page | Unit Read (or org member) |
| Create/edit packages | Unit Write |
| Generate/revoke/renew keys | Unit Write |
| Edit keys | Unit Write |
| Set custom key value | Site admin or org owner |
| Delete packages | Site admin only |
| Edit/delete master package | Blocked (not allowed) |
| Edit master keys | Blocked (only revoke allowed) |
**Note**: Admin-level teams implicitly have admin access to all units (including Licenses) even without explicit TeamUnit records. This means existing admin teams automatically gain license access.
### Key Renewal
The **Renew** action extends a key's expiration by the package's `DurationDays`:
- If the key is still valid, renewal extends from the current expiry date
- If the key has already expired, renewal extends from now
- Renewal also re-activates revoked keys
- For lifetime packages (DurationDays=0), renewal extends by 365 days
### Custom Keys
Site admins and org owners can set a **custom key value** instead of auto-generating. Use cases:
- Migrating from an existing licensing system
- Keys that match external records or payment systems
- Deterministic keys for testing
The custom key is hashed identically to auto-generated keys — no security difference.
## Package Archiving
Packages can be **archived** instead of permanently deleted. This is the recommended workflow for end-of-life packages.
**How archiving works:**
- Archived packages are hidden from the main list
- Existing keys from archived packages **continue to work** (validation, heartbeats, update feeds)
- No new keys can be generated from an archived package
- Archived packages appear in a collapsible "Archived Packages" section at the bottom
- Admins can restore (unarchive) a package at any time
- Only site admins and org owners can permanently delete a package
**Permanent deletion** removes the package record entirely. Any keys that reference it will become orphaned and fail validation. Use with caution.
## Step-by-Step: Setting Up Licensing for a Joomla Extension
### 1. Enable Licensing
**Organization level** (recommended — applies to all repos):
1. Go to your org → **Settings** → **Licensing & Update Streams**
2. Check **Enable licensing for this organization**
3. Check **Require license key for all update feeds**
4. Click **Save**
**Repository level** (per-repo override):
1. Go to your repo → **Settings** → scroll to **Licensing & Updates**
2. Check **Enable licensing for this repository**
3. Select the **Update Feed Format** (Joomla, Dolibarr, or Both)
4. Check **Require license key for update feeds**
5. Click **Save Changes**
### 2. Configure Extension Metadata
In **Organization Settings → Licensing & Update Streams**, under **Extension Metadata**:
1. Set the **Platform** (Joomla, Dolibarr, WordPress, etc.)
2. Set the **Extension Name** (e.g., `pkg_mokowaas`) — this becomes the `<element>` in the XML feed
3. Set the **Display Name** (e.g., "Package - MokoWaaS") — shown in Joomla update manager
4. Set the **Extension Type** (component, module, plugin, package, template, library)
5. Set the **Target Version** regex (e.g., `(5|6)\..*` for Joomla 5 and 6)
6. Set the **PHP Minimum** if applicable (e.g., `8.1`)
7. Set **Maintainer** and **Maintainer URL**
### 3. Create a License Package
1. Navigate to the **Licenses** tab (repo or org level)
2. Click **New Package**
3. Fill in:
- **Name**: e.g., "Pro Annual"
- **Duration**: e.g., `365` days (0 = lifetime)
- **Max Sites**: e.g., `3` (0 = unlimited)
- **Channels**: check which update streams this package grants access to
4. Click **Create License Package**
### 4. Generate a License Key
1. On the Licenses page, find your package row
2. Click the **+** button to generate a key
3. The full key (e.g., `MOKO-A1B2-C3D4-E5F6-G7H8`) is shown in a success banner
4. Copy the key and send it to your customer
### 5. Set Up the Client (Joomla Example)
In the customer's Joomla admin:
1. Go to **System → Update Sites**
2. Add or edit the update site URL: `https://git.mokoconsulting.tech/{org}/{repo}/updates.xml`
3. If prompted for a **Download Key**, enter the license key
4. Joomla will automatically append `&dlid=MOKO-XXXX-...` to download requests
### 6. Monitor Usage
- The **Last Seen** column shows when each key last checked for updates
- Click the edit (pencil) button to view/modify key details
- Domain restrictions are auto-populated on first use (lock-on-first-use)
## Step-by-Step: Payment Integration (Stripe/PayPal Webhook)
### 1. Create a Webhook Endpoint
Configure your payment provider to POST to:
```
POST https://git.mokoconsulting.tech/api/v1/repos/{org}/{repo}/license-keys/purchase
```
With headers:
```
Authorization: token YOUR_API_TOKEN
Content-Type: application/json
```
### 2. Webhook Payload
```json
{
"package_id": 1,
"licensee_name": "John Doe",
"licensee_email": "john@example.com",
"domain": "example.com",
"payment_ref": "stripe_pi_abc123"
}
```
The `payment_ref` provides **idempotency** — duplicate webhooks return the existing key instead of creating duplicates.
### 3. Response
On first creation:
```json
{
"id": 42,
"raw_key": "MOKO-A1B2-C3D4-E5F6-G7H8",
"package_id": 1,
"licensee_name": "John Doe",
...
}
```
Send the `raw_key` to the customer via email.
## Step-by-Step: Validating a Key (Client-Side)
Your extension can validate its own key against the server:
```
POST https://git.mokoconsulting.tech/api/v1/repos/{org}/{repo}/license-keys/validate
Content-Type: application/json
{"key": "MOKO-A1B2-C3D4-E5F6-G7H8", "domain": "example.com"}
```
Response:
```json
{
"valid": true,
"package_name": "Pro Annual",
"channels": "[\"stable\",\"release-candidate\"]",
"expires_at": "2027-06-01T00:00:00Z",
"sites_used": 1,
"max_sites": 3
}
```
This endpoint is **public** — no authentication required. Use it in your extension's license check flow.
## Supported Platforms
The update feed system currently supports:
| Platform | Feed URL | Format | Status |
|---|---|---|---|
| **Joomla** | `/{repo}/updates.xml` | XML with `<downloadkey>` | Production |
| **Dolibarr** | `/{repo}/updates/dolibarr.json` | JSON | Production |
| **WordPress** | `/{repo}/updates/wordpress.json` | PUC-compatible JSON | Production |
| **Drupal** | Planned | XML/JSON | Planned (#353) |
| **PrestaShop** | Planned | XML | Planned (#352) |
| **Composer** | Planned | packages.json | Planned (#354) |
| **WHMCS** | Planned | Custom | Planned (#355) |
All platforms share the same licensing backend — the same keys, packages, and validation work across all feed formats.
---
*Repo: [MokoGitea](https://git.mokoconsulting.tech/MokoConsulting/MokoGitea)*
| Revision | Date | Author | Description |
|---|---|---|---|
| 1.0 | 2026-05-30 | Jonathan Miller (@jmiller) | Initial version |
| 1.1 | 2026-05-31 | Jonathan Miller (@jmiller) | Add feature toggle, unified update system, tag enforcement, heartbeat tracking |
| 1.2 | 2026-05-31 | Jonathan Miller (@jmiller) | Add permissions (TypeLicenses unit), renewal, auto-domain, custom keys, UI/UX cleanup |
| 1.3 | 2026-06-01 | Jonathan Miller (@jmiller) | Add package archiving, expanded delete permissions, migration v340, API renew, step-by-step guides |
| 1.4 | 2026-06-02 | Jonathan Miller (@jmiller) | WordPress feed, feed visibility modes, download gating, RepoScope enforcement, API package CRUD, settings API, combolist channel picker, double confirmation modals, extension metadata in repo settings, domain lock timer, Joomla-standard tags, SHA256 in XML, changelog XML, no-download release page mode |