diff --git a/CHANGELOG.md b/CHANGELOG.md index 361958f37f..26ff1a9142 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/models/licenses/license_key.go b/models/licenses/license_key.go index 32d777421d..13ef323b9f 100644 --- a/models/licenses/license_key.go +++ b/models/licenses/license_key.go @@ -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) diff --git a/models/licenses/license_package.go b/models/licenses/license_package.go index f2d25ab93f..06bb20bd32 100644 --- a/models/licenses/license_package.go +++ b/models/licenses/license_package.go @@ -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. diff --git a/models/licenses/update_stream_config.go b/models/licenses/update_stream_config.go index d203f2276e..a411416507 100644 --- a/models/licenses/update_stream_config.go +++ b/models/licenses/update_stream_config.go @@ -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") diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index a3626f7b8a..8a2b19682d 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -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 } diff --git a/models/migrations/v1_27/v340.go b/models/migrations/v1_27/v340.go new file mode 100644 index 0000000000..1f26764e00 --- /dev/null +++ b/models/migrations/v1_27/v340.go @@ -0,0 +1,66 @@ +// Copyright 2026 Moko Consulting +// 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), + ) +} diff --git a/modules/structs/license_key.go b/modules/structs/license_key.go index 25602dd080..849ee98cd0 100644 --- a/modules/structs/license_key.go +++ b/modules/structs/license_key.go @@ -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"` diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 912ef51dfa..ccc4e31619 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -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", diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index be8f883809..b35889db6f 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -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()) diff --git a/routers/api/v1/repo/license_key.go b/routers/api/v1/repo/license_key.go index 9b63e451c6..a42ff8df2d 100644 --- a/routers/api/v1/repo/license_key.go +++ b/routers/api/v1/repo/license_key.go @@ -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 } diff --git a/routers/web/org/licenses.go b/routers/web/org/licenses.go index 1eba5409f2..17d91cc307 100644 --- a/routers/web/org/licenses.go +++ b/routers/web/org/licenses.go @@ -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 } diff --git a/routers/web/org/update_streams.go b/routers/web/org/update_streams.go index 6f019ab62f..fcf3de0876 100644 --- a/routers/web/org/update_streams.go +++ b/routers/web/org/update_streams.go @@ -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"), diff --git a/routers/web/repo/changelog_xml.go b/routers/web/repo/changelog_xml.go new file mode 100644 index 0000000000..3f56b0e005 --- /dev/null +++ b/routers/web/repo/changelog_xml.go @@ -0,0 +1,193 @@ +// Copyright 2026 Moko Consulting +// 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) +} diff --git a/routers/web/repo/download_gating.go b/routers/web/repo/download_gating.go new file mode 100644 index 0000000000..cfe204c915 --- /dev/null +++ b/routers/web/repo/download_gating.go @@ -0,0 +1,78 @@ +// Copyright 2026 Moko Consulting +// 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 "" +} diff --git a/routers/web/repo/licenses.go b/routers/web/repo/licenses.go index cb5846bd68..7f31859bd2 100644 --- a/routers/web/repo/licenses.go +++ b/routers/web/repo/licenses.go @@ -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 } diff --git a/routers/web/repo/release.go b/routers/web/repo/release.go index 8b2ee174b8..22a15913c0 100644 --- a/routers/web/repo/release.go +++ b/routers/web/repo/release.go @@ -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") diff --git a/routers/web/repo/repo.go b/routers/web/repo/repo.go index 899c3e00c1..cd987a9941 100644 --- a/routers/web/repo/repo.go +++ b/routers/web/repo/repo.go @@ -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) { diff --git a/routers/web/repo/setting/setting.go b/routers/web/repo/setting/setting.go index 4b54f4c56c..b2529c2d2d 100644 --- a/routers/web/repo/setting/setting.go +++ b/routers/web/repo/setting/setting.go @@ -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 { diff --git a/routers/web/repo/updateserver.go b/routers/web/repo/updateserver.go index 3902874423..ed245fb59e 100644 --- a/routers/web/repo/updateserver.go +++ b/routers/web/repo/updateserver.go @@ -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(``)) 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) +} diff --git a/routers/web/web.go b/routers/web/web.go index bf55b889ca..31d6dbb9bd 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -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) diff --git a/services/context/repo.go b/services/context/repo.go index c13f96c704..4eaa997fe7 100644 --- a/services/context/repo.go +++ b/services/context/repo.go @@ -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() diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index cb1ce12704..f8b4cce9c5 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -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 diff --git a/services/updateserver/joomla.go b/services/updateserver/joomla.go index 2fe229b67c..808f644887 100644 --- a/services/updateserver/joomla.go +++ b/services/updateserver/joomla.go @@ -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: " \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 +} diff --git a/services/updateserver/wordpress.go b/services/updateserver/wordpress.go new file mode 100644 index 0000000000..9fb469252d --- /dev/null +++ b/services/updateserver/wordpress.go @@ -0,0 +1,224 @@ +// Copyright 2026 Moko Consulting +// 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"] = "

" + html.EscapeString(cfg.Description) + "

" + } + + // 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("

%s - %s

\n", version, date)) + + // Convert markdown list items to HTML list. + lines := strings.Split(rel.Note, "\n") + b.WriteString("
    \n") + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "- ") || strings.HasPrefix(trimmed, "* ") { + b.WriteString(fmt.Sprintf("
  • %s
  • \n", html.EscapeString(strings.TrimLeft(trimmed[2:], " ")))) + } + } + b.WriteString("
\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), + } +} diff --git a/templates/org/licenses.tmpl b/templates/org/licenses.tmpl index 9355008f95..27fbc82a85 100644 --- a/templates/org/licenses.tmpl +++ b/templates/org/licenses.tmpl @@ -45,30 +45,39 @@ -
-
+
+

0 = {{ctx.Locale.Tr "repo.licenses.lifetime"}}

-
+

0 = {{ctx.Locale.Tr "repo.licenses.unlimited"}}

+
+ + +

{{ctx.Locale.Tr "repo.licenses.domain_lock_hours_help"}}

+
- - {{if $.AvailableStreams}} - {{range $.AvailableStreams}} -
- - -
- {{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"}}

{{ctx.Locale.Tr "repo.licenses.channels_help"}}

+
+ + +

{{ctx.Locale.Tr "repo.licenses.repo_scope_help"}}

+
@@ -110,8 +119,11 @@ {{svg "octicon-pencil" 14}} - {{if $.IsSiteAdmin}} - + {{if $.CanDelete}} + {{end}} @@ -131,20 +143,30 @@ {{end}}
- {{if .LicenseKeys}} + {{if or .LicenseKeys .SearchQuery}}

{{svg "octicon-lock" 16}} {{ctx.Locale.Tr "repo.licenses.issued_keys"}}

- + +
+ + + {{if .SearchQuery}}{{ctx.Locale.Tr "repo.licenses.clear_search"}}{{end}} +
+ {{if .SearchQuery}}

{{ctx.Locale.Tr "repo.licenses.search_results" (len .LicenseKeys) .SearchQuery}}

{{end}} + + {{if .LicenseKeys}} +
+ - {{if .IsRepoAdmin}}{{end}} + {{if .IsRepoAdmin}}{{end}} @@ -157,7 +179,8 @@ {{if .IsInternal}} {{ctx.Locale.Tr "repo.licenses.master_label"}}{{end}} - + + @@ -174,8 +197,8 @@ - {{if $.IsSiteAdmin}} - {{end}} @@ -185,8 +208,80 @@ {{end}}
{{ctx.Locale.Tr "repo.licenses.key_prefix"}} {{ctx.Locale.Tr "repo.licenses.licensee"}}{{ctx.Locale.Tr "repo.licenses.domain"}} {{ctx.Locale.Tr "repo.licenses.expires"}} {{ctx.Locale.Tr "repo.licenses.last_seen"}} {{ctx.Locale.Tr "repo.licenses.status"}}
{{.LicenseeName}}{{if .LicenseeEmail}} ({{.LicenseeEmail}}){{end}}{{.LicenseeName}}{{if .LicenseeEmail}} ({{.LicenseeEmail}}){{end}}{{if .DomainRestriction}}{{.DomainRestriction}}{{else}}{{ctx.Locale.Tr "repo.licenses.any_domain"}}{{end}} {{if eq .ExpiresUnix 0}}{{ctx.Locale.Tr "repo.licenses.never"}}{{else}}{{DateUtils.AbsoluteShort .ExpiresUnix}}{{end}} {{if eq .LastHeartbeatUnix 0}}{{ctx.Locale.Tr "repo.licenses.never"}}{{else}}{{DateUtils.AbsoluteShort .LastHeartbeatUnix}}{{end}} {{if .IsActive}}{{ctx.Locale.Tr "repo.licenses.active"}}{{else}}{{ctx.Locale.Tr "repo.licenses.inactive"}}{{end}}
+ {{else if .SearchQuery}} +

{{ctx.Locale.Tr "repo.licenses.no_search_results"}}

+ {{end}}
{{end}} + + {{/* ── Archived Packages ── */}} + {{if .ArchivedPackages}} +
+ {{svg "octicon-archive" 14}} {{ctx.Locale.Tr "repo.licenses.archived_packages"}} ({{len .ArchivedPackages}}) +
+ + + + + + {{if .IsRepoAdmin}}{{end}} + + + + {{range .ArchivedPackages}} + + + + {{if $.IsRepoAdmin}} + + {{end}} + + {{end}} + +
{{ctx.Locale.Tr "repo.licenses.package_name"}}{{ctx.Locale.Tr "repo.licenses.keys_issued"}}
{{.Name}}{{if .Description}}
{{.Description}}{{end}}
{{.KeyCount}} + + {{if $.CanDelete}} + + {{end}} +
+
+
+ {{end}}
+ + + + + {{template "base/footer" .}} diff --git a/templates/org/licenses_edit_package.tmpl b/templates/org/licenses_edit_package.tmpl index fe0ea15e02..7da7e0cf1d 100644 --- a/templates/org/licenses_edit_package.tmpl +++ b/templates/org/licenses_edit_package.tmpl @@ -30,15 +30,12 @@

0 = {{ctx.Locale.Tr "repo.licenses.unlimited"}}

- - {{if .AvailableStreams}} - {{range .AvailableStreams}} -
- - -
- {{end}} - {{end}} + + +

{{ctx.Locale.Tr "repo.licenses.domain_lock_hours_help"}}

+
+
+ {{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"}}

{{ctx.Locale.Tr "repo.licenses.channels_help"}}

diff --git a/templates/org/settings/update_streams.tmpl b/templates/org/settings/update_streams.tmpl index 2706855faf..e17151d4ca 100644 --- a/templates/org/settings/update_streams.tmpl +++ b/templates/org/settings/update_streams.tmpl @@ -27,6 +27,32 @@

{{ctx.Locale.Tr "org.settings.require_key_help"}}

+
+ + +

{{ctx.Locale.Tr "org.settings.feed_visibility_help"}}

+
+ +
+ + +

{{ctx.Locale.Tr "org.settings.download_gating_help"}}

+
+ +
+ + +

{{ctx.Locale.Tr "org.settings.support_url_help"}}

+
+
{{/* ── Section 2: Extension Metadata ── */}} diff --git a/templates/repo/licenses.tmpl b/templates/repo/licenses.tmpl index 6279922c5f..a0b7a710c6 100644 --- a/templates/repo/licenses.tmpl +++ b/templates/repo/licenses.tmpl @@ -66,8 +66,11 @@ {{svg "octicon-pencil" 14}} - {{if $.IsSiteAdmin}} - + {{if $.CanDelete}} + {{end}} @@ -105,30 +108,39 @@ -
-
+
+

0 = {{ctx.Locale.Tr "repo.licenses.lifetime"}}

-
+

0 = {{ctx.Locale.Tr "repo.licenses.unlimited"}}

+
+ + +

{{ctx.Locale.Tr "repo.licenses.domain_lock_hours_help"}}

+
- - {{if .AvailableStreams}} - {{range .AvailableStreams}} -
- - -
- {{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"}}

{{ctx.Locale.Tr "repo.licenses.channels_help"}}

+
+ + +

{{ctx.Locale.Tr "repo.licenses.repo_scope_help"}}

+
@@ -137,20 +149,30 @@ {{end}} {{/* ── Issued Keys ── */}} - {{if .LicenseKeys}} + {{if or .LicenseKeys .SearchQuery}}

{{svg "octicon-lock" 16}} {{ctx.Locale.Tr "repo.licenses.issued_keys"}}

- + +
+ + + {{if .SearchQuery}}{{ctx.Locale.Tr "repo.licenses.clear_search"}}{{end}} +
+ {{if .SearchQuery}}

{{ctx.Locale.Tr "repo.licenses.search_results" (len .LicenseKeys) .SearchQuery}}

{{end}} + + {{if .LicenseKeys}} +
+ - {{if .IsRepoAdmin}}{{end}} + {{if .IsRepoAdmin}}{{end}} @@ -163,7 +185,8 @@ {{if .IsInternal}} {{ctx.Locale.Tr "repo.licenses.master_label"}}{{end}} - + + @@ -180,8 +203,8 @@ - {{if $.IsSiteAdmin}} - {{end}} @@ -191,9 +214,50 @@ {{end}}
{{ctx.Locale.Tr "repo.licenses.key_prefix"}} {{ctx.Locale.Tr "repo.licenses.licensee"}}{{ctx.Locale.Tr "repo.licenses.domain"}} {{ctx.Locale.Tr "repo.licenses.expires"}} {{ctx.Locale.Tr "repo.licenses.last_seen"}} {{ctx.Locale.Tr "repo.licenses.status"}}
{{.LicenseeName}}{{if .LicenseeEmail}} ({{.LicenseeEmail}}){{end}}{{.LicenseeName}}{{if .LicenseeEmail}} ({{.LicenseeEmail}}){{end}}{{if .DomainRestriction}}{{.DomainRestriction}}{{else}}{{ctx.Locale.Tr "repo.licenses.any_domain"}}{{end}} {{if eq .ExpiresUnix 0}}{{ctx.Locale.Tr "repo.licenses.never"}}{{else}}{{DateUtils.AbsoluteShort .ExpiresUnix}}{{end}} {{if eq .LastHeartbeatUnix 0}}{{ctx.Locale.Tr "repo.licenses.never"}}{{else}}{{DateUtils.AbsoluteShort .LastHeartbeatUnix}}{{end}} {{if .IsActive}}{{ctx.Locale.Tr "repo.licenses.active"}}{{else}}{{ctx.Locale.Tr "repo.licenses.inactive"}}{{end}}
+ {{else if .SearchQuery}} +

{{ctx.Locale.Tr "repo.licenses.no_search_results"}}

+ {{end}}
{{end}} + {{/* ── Archived Packages ── */}} + {{if .ArchivedPackages}} +
+ {{svg "octicon-archive" 14}} {{ctx.Locale.Tr "repo.licenses.archived_packages"}} ({{len .ArchivedPackages}}) +
+ + + + + + {{if .IsRepoAdmin}}{{end}} + + + + {{range .ArchivedPackages}} + + + + {{if $.IsRepoAdmin}} + + {{end}} + + {{end}} + +
{{ctx.Locale.Tr "repo.licenses.package_name"}}{{ctx.Locale.Tr "repo.licenses.keys_issued"}}
{{.Name}}{{if .Description}}
{{.Description}}{{end}}
{{.KeyCount}} + + {{if $.CanDelete}} + + {{end}} +
+
+
+ {{end}} + {{/* ── Update Feed URLs ── */}} {{if .LicensingEnabled}}

@@ -218,8 +282,57 @@

{{end}} + {{if eq .RepoUpdatePlatform "wordpress"}} +
+ +
+ + +
+
+ {{end}} +
+ +
+ + +
+
{{end}} + +{{/* ── Delete Package Confirmation Modal ── */}} + + +{{/* ── Delete Key Confirmation Modal ── */}} + + {{template "base/footer" .}} diff --git a/templates/repo/licenses_edit_package.tmpl b/templates/repo/licenses_edit_package.tmpl index 85fa31d536..f478d547b5 100644 --- a/templates/repo/licenses_edit_package.tmpl +++ b/templates/repo/licenses_edit_package.tmpl @@ -30,15 +30,12 @@

0 = {{ctx.Locale.Tr "repo.licenses.unlimited"}}

- - {{if .AvailableStreams}} - {{range .AvailableStreams}} -
- - -
- {{end}} - {{end}} + + +

{{ctx.Locale.Tr "repo.licenses.domain_lock_hours_help"}}

+
+
+ {{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"}}

{{ctx.Locale.Tr "repo.licenses.channels_help"}}

diff --git a/templates/repo/release/list.tmpl b/templates/repo/release/list.tmpl index 1009d22437..4d7b952a5e 100644 --- a/templates/repo/release/list.tmpl +++ b/templates/repo/release/list.tmpl @@ -73,6 +73,11 @@ {{$release.RenderedNote}}
+ {{if $.HideReleaseDownloads}} +
+

{{ctx.Locale.Tr "repo.release.downloads_require_login"}}

+
+ {{else}}
{{ctx.Locale.Tr "repo.release.downloads"}} @@ -107,6 +112,7 @@ {{end}}
+ {{end}}{{/* end HideReleaseDownloads */}} {{end}} diff --git a/templates/repo/settings/options.tmpl b/templates/repo/settings/options.tmpl index d147f5d439..2feac4d0c5 100644 --- a/templates/repo/settings/options.tmpl +++ b/templates/repo/settings/options.tmpl @@ -544,6 +544,66 @@

{{ctx.Locale.Tr "repo.settings.require_update_key_help"}}

+
+ + +

{{ctx.Locale.Tr "org.settings.download_gating_help"}}

+
+
+ + +

{{ctx.Locale.Tr "repo.settings.support_url_help"}}

+
+ +
+
{{ctx.Locale.Tr "repo.settings.extension_metadata"}}
+

{{ctx.Locale.Tr "repo.settings.extension_metadata_desc"}}

+ +
+
+ + +

{{ctx.Locale.Tr "org.settings.extension_name_help"}}

+
+
+ + +

{{ctx.Locale.Tr "org.settings.display_name_help"}}

+
+
+
+
+ + +
+
+ + +

{{ctx.Locale.Tr "org.settings.target_version_help"}}

+
+
+
+
+ + +
+
+ + +
+
{{$isPackagesEnabled := .Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypePackages}} diff --git a/tests/integration/api_license_keys_test.go b/tests/integration/api_license_keys_test.go new file mode 100644 index 0000000000..d9807b9856 --- /dev/null +++ b/tests/integration/api_license_keys_test.go @@ -0,0 +1,194 @@ +// Copyright 2026 Moko Consulting +// 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. + }) +} diff --git a/wiki/license-management.md b/wiki/license-management.md new file mode 100644 index 0000000000..4ce58f6baf --- /dev/null +++ b/wiki/license-management.md @@ -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 `` element: + +```xml + + MokoOnyx + 3.10.9 + + https://git.mokoconsulting.tech/.../mokoonyx.zip + + + +``` + +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 `` 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 `` | 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 |