feat(licenses): full commercial license management system v1.26.1-moko.06.02.00 #402
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 = ¬es
|
||||
default:
|
||||
currentCategory = ¬es
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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" .}}
|
||||
|
||||
@@ -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
@@ -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" .}}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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.
|
||||
})
|
||||
}
|
||||
@@ -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="&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 |
|
||||
Reference in New Issue
Block a user