From 448b7d3ab0c03a4bc5c572f962190ddf5e32af65 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Mon, 1 Jun 2026 04:45:20 -0500 Subject: [PATCH 01/15] feat(licenses): archive, search, download gating, changelog XML, and expanded permissions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration v340: sync all missing columns (key_raw, payment_ref, last_heartbeat_unix, is_archived, licensing_enabled, download_gating, support_url, and all extension metadata fields). Package archiving (#384): add IsArchived field with archive/unarchive handlers and collapsible "Archived Packages" section in templates. Existing keys from archived packages continue to work. Expanded delete permissions (#385): org owners and site admins can permanently delete packages and keys (previously site admin only). Search (#392): server-side search across key_prefix, key_raw, licensee_name, licensee_email, domain_restriction, and payment_ref via ?q= query parameter on both repo and org licenses pages. Sortable tables (#390): Fomantic UI sortable class on keys table with new Domain column showing DomainRestriction per key. Download gating (#347): three modes — none, prerelease-only, and all downloads. CheckDownloadGating() intercepts both release attachment and git archive download handlers. Support URL (#393): configurable SupportURL field on UpdateStreamConfig for wiki or external site links. Changelog XML (#343): ServeChangelogXML endpoint at /changelog.xml generates Joomla-compatible changelog from release notes. Parses Keep-a-Changelog markdown sections into , , , , , XML elements. API renew (#387): POST /license-keys/{id}/renew endpoint extends key expiration by package duration. Closes #384, #385, #386, #387, #389, #390, #392, #393 Refs #343, #346, #347 Co-Authored-By: Claude Opus 4.6 (1M context) --- models/licenses/license_key.go | 11 ++ models/licenses/license_package.go | 23 ++- models/licenses/update_stream_config.go | 2 + models/migrations/migrations.go | 2 + models/migrations/v1_27/v340.go | 63 +++++++ options/locale/locale_en-US.json | 23 +++ routers/api/v1/api.go | 1 + routers/api/v1/repo/license_key.go | 30 ++++ routers/web/org/licenses.go | 70 +++++++- routers/web/org/update_streams.go | 2 + routers/web/repo/changelog_xml.go | 193 +++++++++++++++++++++ routers/web/repo/download_gating.go | 78 +++++++++ routers/web/repo/licenses.go | 70 +++++++- routers/web/repo/repo.go | 19 ++ routers/web/repo/setting/setting.go | 2 + routers/web/web.go | 5 + services/forms/repo_form.go | 2 + services/updateserver/joomla.go | 2 +- templates/org/licenses.tmpl | 67 ++++++- templates/org/settings/update_streams.tmpl | 16 ++ templates/repo/licenses.tmpl | 74 +++++++- templates/repo/settings/options.tmpl | 14 ++ 22 files changed, 744 insertions(+), 25 deletions(-) create mode 100644 models/migrations/v1_27/v340.go create mode 100644 routers/web/repo/changelog_xml.go create mode 100644 routers/web/repo/download_gating.go diff --git a/models/licenses/license_key.go b/models/licenses/license_key.go index 32d777421d..d84361cc6a 100644 --- a/models/licenses/license_key.go +++ b/models/licenses/license_key.go @@ -123,6 +123,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) diff --git a/models/licenses/license_package.go b/models/licenses/license_package.go index f2d25ab93f..2a4bc11fc7 100644 --- a/models/licenses/license_package.go +++ b/models/licenses/license_package.go @@ -30,6 +30,7 @@ type LicensePackage struct { // 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 +72,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..8e512d1db7 100644 --- a/models/licenses/update_stream_config.go +++ b/models/licenses/update_stream_config.go @@ -27,6 +27,8 @@ 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 + 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..4839070917 --- /dev/null +++ b/models/migrations/v1_27/v340.go @@ -0,0 +1,63 @@ +// 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"` +} + +func (licenseKey340) TableName() string { + return "license_key" +} + +// licensePackage340 adds the IsArchived column. +type licensePackage340 struct { + ID int64 `xorm:"pk autoincr"` + IsArchived bool `xorm:"NOT NULL DEFAULT false"` +} + +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"` + 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/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 912ef51dfa..4ad4b8d96f 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -2676,6 +2676,7 @@ "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_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 +2690,21 @@ "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.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.draft": "Draft", "repo.release.prerelease": "Pre-Release", "repo.release.stable": "Stable", @@ -2837,6 +2853,13 @@ "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.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..df05351053 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1358,6 +1358,7 @@ func Routes() *web.Router { m.Group("/{id}", func() { m.Delete("", repo.DeleteLicenseKey) m.Patch("", bind(api.EditLicenseKeyOption{}), repo.EditLicenseKey) + 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..dd36bb0299 100644 --- a/routers/api/v1/repo/license_key.go +++ b/routers/api/v1/repo/license_key.go @@ -259,6 +259,36 @@ 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 + } + + 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)) +} + // DeleteLicenseKey deletes a license key. func DeleteLicenseKey(ctx *context.APIContext) { if err := licenses.DeleteLicenseKey(ctx, ctx.PathParamInt64("id")); err != nil { diff --git a/routers/web/org/licenses.go b/routers/web/org/licenses.go index 1eba5409f2..6ed4d37440 100644 --- a/routers/web/org/licenses.go +++ b/routers/web/org/licenses.go @@ -87,7 +87,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,8 +105,22 @@ 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 + // 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) if orgCfg != nil { ctx.Data["AvailableStreams"] = orgCfg.GetActiveStreams() @@ -295,9 +318,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 +498,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..b91a726961 100644 --- a/routers/web/org/update_streams.go +++ b/routers/web/org/update_streams.go @@ -44,6 +44,8 @@ func SettingsUpdateStreamsPost(ctx *context.Context) { CustomStreams: ctx.FormString("custom_streams"), LicensingEnabled: ctx.FormString("licensing_enabled") == "on", RequireKey: ctx.FormString("require_key") == "on", + 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..3717a0f0c7 --- /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.ValidateLicenseKey(ctx, rawKey, domain) + 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..76bd8e2943 100644 --- a/routers/web/repo/licenses.go +++ b/routers/web/repo/licenses.go @@ -87,12 +87,35 @@ 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 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 multiselect. orgCfg, _ := licenses.GetOrgConfig(ctx, ownerID) @@ -386,9 +409,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 +501,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/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..ecda91d803 100644 --- a/routers/web/repo/setting/setting.go +++ b/routers/web/repo/setting/setting.go @@ -686,6 +686,8 @@ func handleSettingsPostAdvanced(ctx *context.Context) { Platform: updatePlatform, LicensingEnabled: form.EnableLicensing, RequireKey: form.RequireUpdateKey, + DownloadGating: form.DownloadGating, + SupportURL: form.SupportURL, StreamMode: "joomla", // inherit org default } if err := licenses_model.SaveConfig(ctx, updateCfg); err != nil { diff --git a/routers/web/web.go b/routers/web/web.go index bf55b889ca..f6db1b0239 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,7 @@ 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("/changelog.xml", repo.ServeChangelogXML) }, optSignIn, context.RepoAssignment) // end "/{username}/{reponame}": update server @@ -1528,6 +1531,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/forms/repo_form.go b/services/forms/repo_form.go index cb1ce12704..a0ab33d61a 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -136,6 +136,8 @@ type RepoSettingForm struct { UpdatePlatform string RequireUpdateKey bool EnableLicensing bool + DownloadGating string + SupportURL string EnablePackages bool diff --git a/services/updateserver/joomla.go b/services/updateserver/joomla.go index 2fe229b67c..dc501409eb 100644 --- a/services/updateserver/joomla.go +++ b/services/updateserver/joomla.go @@ -277,7 +277,7 @@ func GenerateJoomlaXML(ctx context.Context, repo *repo_model.Repository, require }, }, Tags: xmlTags{Tag: ch}, - ChangelogURL: fmt.Sprintf("%s/raw/branch/%s/CHANGELOG.md", repoLink, repo.DefaultBranch), + ChangelogURL: fmt.Sprintf("%s/changelog.xml", repoLink), Maintainer: maintainer, MaintainerURL: maintainerURL, PHPMinimum: phpMinimum, diff --git a/templates/org/licenses.tmpl b/templates/org/licenses.tmpl index 9355008f95..9d4fd2453d 100644 --- a/templates/org/licenses.tmpl +++ b/templates/org/licenses.tmpl @@ -110,7 +110,10 @@ {{svg "octicon-pencil" 14}} - {{if $.IsSiteAdmin}} + + {{if $.CanDelete}} @@ -131,20 +134,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 +170,8 @@ {{if .IsInternal}} {{ctx.Locale.Tr "repo.licenses.master_label"}}{{end}} - + + @@ -174,7 +188,7 @@ - {{if $.IsSiteAdmin}} + {{if $.CanDelete}} @@ -185,8 +199,49 @@ {{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/settings/update_streams.tmpl b/templates/org/settings/update_streams.tmpl index 2706855faf..e68220487e 100644 --- a/templates/org/settings/update_streams.tmpl +++ b/templates/org/settings/update_streams.tmpl @@ -27,6 +27,22 @@

{{ctx.Locale.Tr "org.settings.require_key_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..3771a6e4d1 100644 --- a/templates/repo/licenses.tmpl +++ b/templates/repo/licenses.tmpl @@ -66,7 +66,10 @@ {{svg "octicon-pencil" 14}} - {{if $.IsSiteAdmin}} + + {{if $.CanDelete}} @@ -137,20 +140,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 +176,8 @@ {{if .IsInternal}} {{ctx.Locale.Tr "repo.licenses.master_label"}}{{end}} - + + @@ -180,7 +194,7 @@ - {{if $.IsSiteAdmin}} + {{if $.CanDelete}} @@ -191,9 +205,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,6 +273,13 @@ {{end}} +
+ +
+ + +
+
{{end}} diff --git a/templates/repo/settings/options.tmpl b/templates/repo/settings/options.tmpl index d147f5d439..03488ae2bf 100644 --- a/templates/repo/settings/options.tmpl +++ b/templates/repo/settings/options.tmpl @@ -544,6 +544,20 @@

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

+
{{$isPackagesEnabled := .Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypePackages}} -- 2.52.0 From 53a5d0b97bb70f5265a8e7bb68694e2bffaf84a3 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Mon, 1 Jun 2026 05:00:50 -0500 Subject: [PATCH 02/15] feat(licenses): domain lock timer, infourl fix, Akeeba-compatible XML format Domain lock timer: add DomainLockHours to LicensePackage and FirstUsedUnix to LicenseKey. During the grace period after first use, any domain is accepted and auto-added to the restriction list. After the grace period, only listed domains are allowed. Set 0 for immediate lock-on-first-use (default). Fix infourl: default to /releases listing page instead of specific tag page. Falls back to SupportURL or InfoURL if configured. Match Akeeba Backup Pro XML format: downloadkey prefix is "dlid=" (not "&dlid="), matching how Joomla stores extra_query. Verified against production Akeeba/JCE/AdminTools manifests via SSH. Update migration v340 with FirstUsedUnix and DomainLockHours columns. Add DomainLockHours field to create/edit package forms for both repo and org levels with help text. Co-Authored-By: Claude Opus 4.6 (1M context) --- models/licenses/license_key.go | 43 +++++++++++++++++++---- models/licenses/license_package.go | 1 + models/migrations/v1_27/v340.go | 8 +++-- options/locale/locale_en-US.json | 2 ++ routers/web/org/licenses.go | 4 +++ routers/web/repo/licenses.go | 4 +++ services/updateserver/joomla.go | 6 ++-- templates/org/licenses.tmpl | 11 ++++-- templates/org/licenses_edit_package.tmpl | 5 +++ templates/repo/licenses.tmpl | 11 ++++-- templates/repo/licenses_edit_package.tmpl | 5 +++ 11 files changed, 82 insertions(+), 18 deletions(-) diff --git a/models/licenses/license_key.go b/models/licenses/license_key.go index d84361cc6a..966e854880 100644 --- a/models/licenses/license_key.go +++ b/models/licenses/license_key.go @@ -37,6 +37,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"` } @@ -182,10 +183,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 } @@ -225,7 +240,10 @@ func ValidateLicenseKey(ctx context.Context, rawKey, domain string) (*LicenseKey // 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) { @@ -234,11 +252,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 @@ -254,7 +284,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 diff --git a/models/licenses/license_package.go b/models/licenses/license_package.go index 2a4bc11fc7..06bb20bd32 100644 --- a/models/licenses/license_package.go +++ b/models/licenses/license_package.go @@ -25,6 +25,7 @@ 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. diff --git a/models/migrations/v1_27/v340.go b/models/migrations/v1_27/v340.go index 4839070917..2a40495781 100644 --- a/models/migrations/v1_27/v340.go +++ b/models/migrations/v1_27/v340.go @@ -15,16 +15,18 @@ type licenseKey340 struct { 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 column. +// licensePackage340 adds the IsArchived and DomainLockHours columns. type licensePackage340 struct { - ID int64 `xorm:"pk autoincr"` - IsArchived bool `xorm:"NOT NULL DEFAULT false"` + ID int64 `xorm:"pk autoincr"` + IsArchived bool `xorm:"NOT NULL DEFAULT false"` + DomainLockHours int `xorm:"NOT NULL DEFAULT 0"` } func (licensePackage340) TableName() string { diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 4ad4b8d96f..4cbf37c065 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -2702,6 +2702,8 @@ "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.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.", diff --git a/routers/web/org/licenses.go b/routers/web/org/licenses.go index 6ed4d37440..ae0a31cb0d 100644 --- a/routers/web/org/licenses.go +++ b/routers/web/org/licenses.go @@ -142,6 +142,7 @@ 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"] var allowedChannels string @@ -156,6 +157,7 @@ func LicensesCreatePackage(ctx *context.Context) { Description: ctx.FormString("description"), DurationDays: durationDays, MaxSites: maxSites, + DomainLockHours: domainLockHours, AllowedChannels: allowedChannels, RepoScope: "all", IsActive: true, @@ -298,6 +300,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 { diff --git a/routers/web/repo/licenses.go b/routers/web/repo/licenses.go index 76bd8e2943..31f5419a6a 100644 --- a/routers/web/repo/licenses.go +++ b/routers/web/repo/licenses.go @@ -139,6 +139,7 @@ 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"] var allowedChannels string @@ -153,6 +154,7 @@ func LicensesCreatePackage(ctx *context.Context) { Description: ctx.FormString("description"), DurationDays: durationDays, MaxSites: maxSites, + DomainLockHours: domainLockHours, AllowedChannels: allowedChannels, RepoScope: "all", IsActive: true, @@ -389,6 +391,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 { diff --git a/services/updateserver/joomla.go b/services/updateserver/joomla.go index dc501409eb..2b7eb42e19 100644 --- a/services/updateserver/joomla.go +++ b/services/updateserver/joomla.go @@ -254,8 +254,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 } diff --git a/templates/org/licenses.tmpl b/templates/org/licenses.tmpl index 9d4fd2453d..c99f856e07 100644 --- a/templates/org/licenses.tmpl +++ b/templates/org/licenses.tmpl @@ -45,17 +45,22 @@ -
-
+
+

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}} diff --git a/templates/org/licenses_edit_package.tmpl b/templates/org/licenses_edit_package.tmpl index fe0ea15e02..9f8bab5843 100644 --- a/templates/org/licenses_edit_package.tmpl +++ b/templates/org/licenses_edit_package.tmpl @@ -29,6 +29,11 @@

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

+
+ + +

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

+
{{if .AvailableStreams}} diff --git a/templates/repo/licenses.tmpl b/templates/repo/licenses.tmpl index 3771a6e4d1..701eff6f0a 100644 --- a/templates/repo/licenses.tmpl +++ b/templates/repo/licenses.tmpl @@ -108,17 +108,22 @@
-
-
+
+

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}} diff --git a/templates/repo/licenses_edit_package.tmpl b/templates/repo/licenses_edit_package.tmpl index 85fa31d536..974764ada3 100644 --- a/templates/repo/licenses_edit_package.tmpl +++ b/templates/repo/licenses_edit_package.tmpl @@ -29,6 +29,11 @@

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

+
+ + +

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

+
{{if .AvailableStreams}} -- 2.52.0 From 0e09723d2a029bd50433f688b88003fc379062e5 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Mon, 1 Jun 2026 05:01:59 -0500 Subject: [PATCH 03/15] fix(updates): map stream names to Joomla-standard tag values Joomla only recognizes: dev, alpha, beta, rc, stable. Our internal stream names use longer forms (development, release-candidate). Add joomlaTagName() to map between conventions in the XML element. Without this, Joomla's update channel filter ignores entries with non-standard tag values like "release-candidate" or "development". Co-Authored-By: Claude Opus 4.6 (1M context) --- services/updateserver/joomla.go | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/services/updateserver/joomla.go b/services/updateserver/joomla.go index 2b7eb42e19..1c6cdb41a6 100644 --- a/services/updateserver/joomla.go +++ b/services/updateserver/joomla.go @@ -105,6 +105,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 { @@ -278,7 +297,7 @@ func GenerateJoomlaXML(ctx context.Context, repo *repo_model.Repository, require {Type: "full", Format: "zip", URL: downloadURL}, }, }, - Tags: xmlTags{Tag: ch}, + Tags: xmlTags{Tag: joomlaTagName(ch)}, ChangelogURL: fmt.Sprintf("%s/changelog.xml", repoLink), Maintainer: maintainer, MaintainerURL: maintainerURL, -- 2.52.0 From 7da0e025da120300a8ad94d4795f4784ebc0a345 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Mon, 1 Jun 2026 05:04:24 -0500 Subject: [PATCH 04/15] feat(updates): include SHA256 from sidecar files in Joomla updates.xml Read the .sha256 sidecar attachment (generated by GenerateReleaseChecksums) and populate the element in the update XML. This matches the pattern used by Akeeba (sha512) and JCE (sha256 + sha384 + sha512) for integrity verification. Also fix zip attachment filter to skip .sha256 sidecar files when selecting the download URL. Co-Authored-By: Claude Opus 4.6 (1M context) --- services/updateserver/joomla.go | 42 ++++++++++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/services/updateserver/joomla.go b/services/updateserver/joomla.go index 1c6cdb41a6..f56758c973 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" ) @@ -246,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) @@ -302,6 +315,7 @@ func GenerateJoomlaXML(ctx context.Context, repo *repo_model.Repository, require Maintainer: maintainer, MaintainerURL: maintainerURL, PHPMinimum: phpMinimum, + SHA256: sha256Hash, TargetPlatform: xmlTargetPlat{ Name: "joomla", Version: targetVersion, @@ -354,3 +368,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 +} -- 2.52.0 From 7c9215de0595bc9e744723210eb4f17f454dadb7 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Mon, 1 Jun 2026 05:09:14 -0500 Subject: [PATCH 05/15] test(licenses): add integration tests for license key API endpoints Tests cover: - Package CRUD (list, create, create without name) - Key CRUD (create, list, edit, renew, delete) - Purchase webhook (new key, idempotent duplicate) - Public validation (valid key, invalid key, domain check) Tests follow existing Gitea integration test patterns using unittest fixtures, user tokens, and DecodeJSON assertions. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/integration/api_license_keys_test.go | 194 +++++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 tests/integration/api_license_keys_test.go 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. + }) +} -- 2.52.0 From 3e8124a2b7174ea781b84ac1089e07482617c6e4 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Mon, 1 Jun 2026 05:21:09 -0500 Subject: [PATCH 06/15] feat(licenses): API package CRUD, settings API, and repo scope UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API package endpoints (#388): - PATCH /license-packages/{id} — edit package (master protected) - DELETE /license-packages/{id} — delete package (master protected) - POST /license-packages/{id}/archive — archive package - POST /license-packages/{id}/unarchive — restore package Settings API (#349): - GET /license-settings — read licensing/update stream config - PUT /license-settings — update config (all metadata fields) - New LicenseSettings struct in API types Repo scope UI (#395): - Dropdown in create/edit package forms listing org repos - "All repositories" default option - RepoScope read from form in both repo and org handlers - OrgRepos loaded via GetOrgRepositories in Licenses handlers Refs #341, #346, #349, #388, #395 Co-Authored-By: Claude Opus 4.6 (1M context) --- modules/structs/license_key.go | 17 ++++ options/locale/locale_en-US.json | 3 + routers/api/v1/api.go | 10 ++ routers/api/v1/repo/license_key.go | 156 +++++++++++++++++++++++++++++ routers/web/org/licenses.go | 11 +- routers/web/repo/licenses.go | 16 ++- templates/org/licenses.tmpl | 12 +++ templates/repo/licenses.tmpl | 12 +++ 8 files changed, 235 insertions(+), 2 deletions(-) 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 4cbf37c065..6a4242e4e5 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -2704,6 +2704,9 @@ "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.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.", diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index df05351053..4a120caf34 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() { diff --git a/routers/api/v1/repo/license_key.go b/routers/api/v1/repo/license_key.go index dd36bb0299..426a604a5d 100644 --- a/routers/api/v1/repo/license_key.go +++ b/routers/api/v1/repo/license_key.go @@ -14,6 +14,61 @@ 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) +} + func toLicensePackageAPI(pkg *licenses.LicensePackage) *structs.LicensePackage { return &structs.LicensePackage{ ID: pkg.ID, @@ -100,6 +155,107 @@ 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 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 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 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) { + if err := licenses.UnarchiveLicensePackage(ctx, ctx.PathParamInt64("id")); 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) diff --git a/routers/web/org/licenses.go b/routers/web/org/licenses.go index ae0a31cb0d..0a4ffcedfa 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" @@ -128,6 +129,9 @@ func Licenses(ctx *context.Context) { ctx.Data["AvailableStreams"] = licenses.DefaultJoomlaStreams() } + orgRepos, _ := repo_model.GetOrgRepositories(ctx, ownerID) + ctx.Data["OrgRepos"] = orgRepos + ctx.HTML(http.StatusOK, tplOrgLicenses) } @@ -151,6 +155,11 @@ func LicensesCreatePackage(ctx *context.Context) { allowedChannels = string(data) } + repoScope := ctx.FormString("repo_scope") + if repoScope == "" { + repoScope = "all" + } + pkg := &licenses.LicensePackage{ OwnerID: ctx.Org.Organization.ID, Name: name, @@ -159,7 +168,7 @@ func LicensesCreatePackage(ctx *context.Context) { MaxSites: maxSites, DomainLockHours: domainLockHours, AllowedChannels: allowedChannels, - RepoScope: "all", + RepoScope: repoScope, IsActive: true, } diff --git a/routers/web/repo/licenses.go b/routers/web/repo/licenses.go index 31f5419a6a..41596d58f7 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" @@ -125,6 +126,10 @@ func Licenses(ctx *context.Context) { ctx.Data["AvailableStreams"] = licenses.DefaultJoomlaStreams() } + // Load org repos for repo scope selector. + orgRepos, _ := repo_model.GetOrgRepositories(ctx, ownerID) + ctx.Data["OrgRepos"] = orgRepos + ctx.HTML(http.StatusOK, tplLicenses) } @@ -148,6 +153,11 @@ func LicensesCreatePackage(ctx *context.Context) { allowedChannels = string(data) } + repoScope := ctx.FormString("repo_scope") + if repoScope == "" { + repoScope = "all" + } + pkg := &licenses.LicensePackage{ OwnerID: ctx.Repo.Repository.OwnerID, Name: name, @@ -156,7 +166,7 @@ func LicensesCreatePackage(ctx *context.Context) { MaxSites: maxSites, DomainLockHours: domainLockHours, AllowedChannels: allowedChannels, - RepoScope: "all", + RepoScope: repoScope, IsActive: true, } @@ -393,6 +403,10 @@ func LicensesEditPackagePost(ctx *context.Context) { 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 { diff --git a/templates/org/licenses.tmpl b/templates/org/licenses.tmpl index c99f856e07..17f6614c4b 100644 --- a/templates/org/licenses.tmpl +++ b/templates/org/licenses.tmpl @@ -74,6 +74,18 @@

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

+
+ + +

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

+
diff --git a/templates/repo/licenses.tmpl b/templates/repo/licenses.tmpl index 701eff6f0a..c6b8950a18 100644 --- a/templates/repo/licenses.tmpl +++ b/templates/repo/licenses.tmpl @@ -137,6 +137,18 @@

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

+
+ + +

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

+
-- 2.52.0 From 93d18ab25fd3943ada8728f2556ad3c5ccdee5d3 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Mon, 1 Jun 2026 05:23:56 -0500 Subject: [PATCH 07/15] feat(licenses): double confirmation modals for permanent deletion (#391) Replace link-action delete buttons with show-modal pattern that opens a Fomantic UI modal with a form. Package deletion requires typing the package name in a required field before the submit button works. Key deletion shows a warning modal with confirmation. Uses Gitea's existing form-fetch-action + modal_actions_confirm pattern, matching how repo deletion works in settings. Both repo and org templates updated with matching modals. Co-Authored-By: Claude Opus 4.6 (1M context) --- options/locale/locale_en-US.json | 3 +++ templates/org/licenses.tmpl | 35 +++++++++++++++++++++++++++--- templates/repo/licenses.tmpl | 37 +++++++++++++++++++++++++++++--- 3 files changed, 69 insertions(+), 6 deletions(-) diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 6a4242e4e5..c88a1c2e2f 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -2707,6 +2707,9 @@ "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.", diff --git a/templates/org/licenses.tmpl b/templates/org/licenses.tmpl index 17f6614c4b..4a42992c42 100644 --- a/templates/org/licenses.tmpl +++ b/templates/org/licenses.tmpl @@ -131,7 +131,7 @@ {{svg "octicon-archive" 14}} {{if $.CanDelete}} - {{end}} @@ -206,7 +206,7 @@ {{svg "octicon-x" 14}} {{if $.CanDelete}} - {{end}} @@ -246,7 +246,7 @@ {{svg "octicon-reply" 14}} {{if $.CanDelete}} - {{end}} @@ -261,4 +261,33 @@ {{end}}
+ + + + + {{template "base/footer" .}} diff --git a/templates/repo/licenses.tmpl b/templates/repo/licenses.tmpl index c6b8950a18..de05e12051 100644 --- a/templates/repo/licenses.tmpl +++ b/templates/repo/licenses.tmpl @@ -70,7 +70,7 @@ {{svg "octicon-archive" 14}} {{if $.CanDelete}} - {{end}} @@ -212,7 +212,7 @@ {{svg "octicon-x" 14}} {{if $.CanDelete}} - {{end}} @@ -252,7 +252,7 @@ {{svg "octicon-reply" 14}} {{if $.CanDelete}} - {{end}} @@ -301,4 +301,35 @@ {{end}} + +{{/* ── Delete Package Confirmation Modal ── */}} + + +{{/* ── Delete Key Confirmation Modal ── */}} + + {{template "base/footer" .}} -- 2.52.0 From d204ecb9f023ce6c697e28572921a567b8a383fd Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Mon, 1 Jun 2026 05:33:26 -0500 Subject: [PATCH 08/15] fix(licenses): enforce RepoScope validation and add API revoke endpoint SECURITY: ValidateLicenseKeyForRepo() now checks the package's RepoScope field against the requesting repo ID. A package scoped to repo A will reject keys when accessed from repo B's update feed. Update server and download gating both use the new function. Master/internal keys bypass repo scope checks. RepoScope supports: "all" (any repo), single repo ID string, or JSON array of repo IDs like ["1","5","12"]. Also adds POST /license-keys/{id}/revoke API endpoint that was missing from the API but existed in web handlers. Co-Authored-By: Claude Opus 4.6 (1M context) --- models/licenses/license_key.go | 38 +++++++++++++++++++++++++++++ routers/api/v1/api.go | 1 + routers/api/v1/repo/license_key.go | 18 ++++++++++++++ routers/web/repo/download_gating.go | 2 +- routers/web/repo/updateserver.go | 2 +- 5 files changed, 59 insertions(+), 2 deletions(-) diff --git a/models/licenses/license_key.go b/models/licenses/license_key.go index 966e854880..618aeb5e63 100644 --- a/models/licenses/license_key.go +++ b/models/licenses/license_key.go @@ -238,6 +238,9 @@ 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() @@ -314,6 +317,41 @@ 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" { + // RepoScope is either a single repo ID or a JSON array of IDs. + scopeStr := pkg.RepoScope + repoIDStr := fmt.Sprintf("%d", repoID) + + if strings.HasPrefix(scopeStr, "[") { + // JSON array format: ["1","2","3"] + if !strings.Contains(scopeStr, repoIDStr) { + return nil, nil, fmt.Errorf("license key not valid for this repository") + } + } else { + // Single repo ID. + if scopeStr != repoIDStr { + 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/routers/api/v1/api.go b/routers/api/v1/api.go index 4a120caf34..b35889db6f 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1368,6 +1368,7 @@ 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) }) diff --git a/routers/api/v1/repo/license_key.go b/routers/api/v1/repo/license_key.go index 426a604a5d..1610a30918 100644 --- a/routers/api/v1/repo/license_key.go +++ b/routers/api/v1/repo/license_key.go @@ -445,6 +445,24 @@ func RenewLicenseKey(ctx *context.APIContext) { 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 + } + + 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 { diff --git a/routers/web/repo/download_gating.go b/routers/web/repo/download_gating.go index 3717a0f0c7..cfe204c915 100644 --- a/routers/web/repo/download_gating.go +++ b/routers/web/repo/download_gating.go @@ -54,7 +54,7 @@ func CheckDownloadGating(ctx *context.Context, tagName string) bool { } domain := ctx.FormString("domain") - key, _, err := licenses.ValidateLicenseKey(ctx, rawKey, domain) + key, _, err := licenses.ValidateLicenseKeyForRepo(ctx, rawKey, domain, repo.ID) if err != nil { log.Debug("Download gating: key validation failed: %v", err) return false diff --git a/routers/web/repo/updateserver.go b/routers/web/repo/updateserver.go index 3902874423..2eeb5fd089 100644 --- a/routers/web/repo/updateserver.go +++ b/routers/web/repo/updateserver.go @@ -37,7 +37,7 @@ func validateUpdateKey(ctx *context.Context) (allowedChannels []string, ok bool) } 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 -- 2.52.0 From 1fabdb94ec07e1713da00ffd4e757d8fa755e4d9 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 2 Jun 2026 00:01:16 -0500 Subject: [PATCH 09/15] feat(updates): WordPress PUC-compatible update feed (#351) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New endpoint: GET /{owner}/{repo}/updates/wordpress.json Generates JSON compatible with the YahnisElsts plugin-update-checker library — the standard for commercial WordPress plugin self-hosted updates. Returns name, slug, version, download_url, homepage, requires_php, author, sections (changelog HTML), icons, and banners. License key validation: reads from ?license_key=, ?dlid=, or ?key= query params (PUC sends these via addQueryArgFilter). When RequireKey is enabled, returns minimal empty response without download_url. Changelog section built from release notes (last 10 stable releases), converting markdown list items to HTML
    /
  • elements. Icon/banner URLs point to conventional paths in the repo: assets/icon-128x128.png, assets/icon-256x256.png assets/banner-772x250.png, assets/banner-1544x500.png Route registered at /updates/wordpress.json alongside existing /updates.xml (Joomla) and /updates/dolibarr.json. Co-Authored-By: Claude Opus 4.6 (1M context) --- options/locale/locale_en-US.json | 1 + routers/web/repo/updateserver.go | 71 +++++++++ routers/web/web.go | 1 + services/updateserver/wordpress.go | 223 +++++++++++++++++++++++++++++ templates/repo/licenses.tmpl | 9 ++ 5 files changed, 305 insertions(+) create mode 100644 services/updateserver/wordpress.go diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index c88a1c2e2f..1d1e5bd152 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -2676,6 +2676,7 @@ "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", diff --git a/routers/web/repo/updateserver.go b/routers/web/repo/updateserver.go index 2eeb5fd089..334a566d90 100644 --- a/routers/web/repo/updateserver.go +++ b/routers/web/repo/updateserver.go @@ -148,3 +148,74 @@ 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 + } + + // Extract license key from query params (PUC client sends via addQueryArgFilter). + licenseKey := ctx.FormString("license_key") + if licenseKey == "" { + licenseKey = ctx.FormString("dlid") + } + if licenseKey == "" { + licenseKey = ctx.FormString("key") + } + + // Validate key if required. + repoCfg, _ := licenses.GetRepoConfig(ctx, ctx.Repo.Repository.ID) + requireKey := repoCfg != nil && repoCfg.RequireKey + if requireKey && licenseKey == "" { + // Return minimal info without download URL. + 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 + } + + if licenseKey != "" { + domain := ctx.FormString("domain") + if domain == "" { + domain = ctx.FormString("url") + } + key, _, err := licenses.ValidateLicenseKeyForRepo(ctx, licenseKey, domain, ctx.Repo.Repository.ID) + if err != nil { + log.Debug("WordPress update key validation failed: %v", err) + 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 + } + _ = licenses.TouchHeartbeat(ctx, key.ID) + _ = licenses.RecordUsage(ctx, &licenses.LicenseKeyUsage{ + KeyID: key.ID, + RepoID: ctx.Repo.Repository.ID, + Domain: domain, + IPAddress: ctx.RemoteAddr(), + UserAgent: ctx.Req.UserAgent(), + VersionFrom: ctx.FormString("installed_version"), + }) + } + + 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 f6db1b0239..31d6dbb9bd 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1518,6 +1518,7 @@ 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 diff --git a/services/updateserver/wordpress.go b/services/updateserver/wordpress.go new file mode 100644 index 0000000000..89ec149b8c --- /dev/null +++ b/services/updateserver/wordpress.go @@ -0,0 +1,223 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package updateserver + +import ( + "context" + "fmt" + "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"] = "

    " + 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", 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/repo/licenses.tmpl b/templates/repo/licenses.tmpl index de05e12051..0fd8be9788 100644 --- a/templates/repo/licenses.tmpl +++ b/templates/repo/licenses.tmpl @@ -290,6 +290,15 @@ {{end}} + {{if eq .RepoUpdatePlatform "wordpress"}} +
    + +
    + + +
    +
    + {{end}}
    -- 2.52.0 From e3a8ae2595d4c66dbb52a82f4b6fe46b9b027b01 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 2 Jun 2026 06:19:48 -0500 Subject: [PATCH 10/15] feat(settings): add extension metadata to repo settings (#335) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repo settings now include extension metadata fields (element name, display name, type, target version, maintainer, PHP minimum) under the Licensing section. These override org-level defaults per-repo. Empty fields inherit from the organization's update stream config. Extension type dropdown includes: package, component, module, plugin, template, library — plus an "(inherit from org)" option. Also adds form fields to the RepoSettingsForm struct for all metadata fields. Closes #335 Co-Authored-By: Claude Opus 4.6 (1M context) --- options/locale/locale_en-US.json | 3 ++ routers/web/repo/setting/setting.go | 6 ++++ services/forms/repo_form.go | 6 ++++ templates/repo/settings/options.tmpl | 46 ++++++++++++++++++++++++++++ 4 files changed, 61 insertions(+) diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 1d1e5bd152..db7defb10c 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -2714,6 +2714,9 @@ "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.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", diff --git a/routers/web/repo/setting/setting.go b/routers/web/repo/setting/setting.go index ecda91d803..b2529c2d2d 100644 --- a/routers/web/repo/setting/setting.go +++ b/routers/web/repo/setting/setting.go @@ -688,6 +688,12 @@ func handleSettingsPostAdvanced(ctx *context.Context) { 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/services/forms/repo_form.go b/services/forms/repo_form.go index a0ab33d61a..f8b4cce9c5 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -138,6 +138,12 @@ type RepoSettingForm struct { EnableLicensing bool DownloadGating string SupportURL string + ExtensionName string + DisplayName string + ExtensionType string + TargetVersion string + Maintainer string + PHPMinimum string EnablePackages bool diff --git a/templates/repo/settings/options.tmpl b/templates/repo/settings/options.tmpl index 03488ae2bf..2feac4d0c5 100644 --- a/templates/repo/settings/options.tmpl +++ b/templates/repo/settings/options.tmpl @@ -558,6 +558,52 @@

    {{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}} -- 2.52.0 From 01eb9944ca88db82311c62ef1d87cc4229bde73c Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 2 Jun 2026 06:30:59 -0500 Subject: [PATCH 11/15] feat(licenses): replace channel checkboxes with combolist picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use the existing shared/combolist.tmpl component for channel selection in create and edit package forms. This mimics the issue label picker UI — a searchable dropdown with checkmarks and a selected-items list below. Replaces raw checkboxes (repo + org templates) and Fomantic multiple selection dropdowns (edit templates) with the combo-multiselect component that has proper JS for toggle, search, and clear functionality. Handler parsing updated: combolist sends comma-separated values in a single hidden input (vs multiple checkbox form values). Co-Authored-By: Claude Opus 4.6 (1M context) --- routers/web/org/licenses.go | 43 ++++++++++++++---- routers/web/repo/licenses.go | 54 ++++++++++++++++++----- templates/org/licenses.tmpl | 10 +---- templates/org/licenses_edit_package.tmpl | 10 +---- templates/repo/licenses.tmpl | 10 +---- templates/repo/licenses_edit_package.tmpl | 10 +---- 6 files changed, 82 insertions(+), 55 deletions(-) diff --git a/routers/web/org/licenses.go b/routers/web/org/licenses.go index 0a4ffcedfa..17d91cc307 100644 --- a/routers/web/org/licenses.go +++ b/routers/web/org/licenses.go @@ -21,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 == "" { @@ -123,11 +141,14 @@ func Licenses(ctx *context.Context) { ctx.Data["ArchivedPackages"] = archivedDisplay orgCfg, _ := licenses.GetOrgConfig(ctx, ownerID) + var orgStreams []licenses.StreamDef if orgCfg != nil { - ctx.Data["AvailableStreams"] = orgCfg.GetActiveStreams() + orgStreams = orgCfg.GetActiveStreams() } else { - ctx.Data["AvailableStreams"] = licenses.DefaultJoomlaStreams() + orgStreams = licenses.DefaultJoomlaStreams() } + ctx.Data["AvailableStreams"] = orgStreams + ctx.Data["ChannelItems"] = buildOrgChannelItems(orgStreams) orgRepos, _ := repo_model.GetOrgRepositories(ctx, ownerID) ctx.Data["OrgRepos"] = orgRepos @@ -148,10 +169,11 @@ func LicensesCreatePackage(ctx *context.Context) { 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) } @@ -276,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) } diff --git a/routers/web/repo/licenses.go b/routers/web/repo/licenses.go index 41596d58f7..7f31859bd2 100644 --- a/routers/web/repo/licenses.go +++ b/routers/web/repo/licenses.go @@ -41,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 @@ -118,13 +137,16 @@ func Licenses(ctx *context.Context) { } ctx.Data["ArchivedPackages"] = archivedDisplay - // Load available streams for the channels multiselect. + // Load available streams for the channels combolist. orgCfg, _ := licenses.GetOrgConfig(ctx, ownerID) + var streams []licenses.StreamDef if orgCfg != nil { - ctx.Data["AvailableStreams"] = orgCfg.GetActiveStreams() + streams = orgCfg.GetActiveStreams() } else { - ctx.Data["AvailableStreams"] = licenses.DefaultJoomlaStreams() + 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) @@ -146,10 +168,12 @@ func LicensesCreatePackage(ctx *context.Context) { 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) } @@ -247,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) } @@ -367,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) } diff --git a/templates/org/licenses.tmpl b/templates/org/licenses.tmpl index 4a42992c42..1c0fc0d0b7 100644 --- a/templates/org/licenses.tmpl +++ b/templates/org/licenses.tmpl @@ -62,15 +62,7 @@

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

    diff --git a/templates/org/licenses_edit_package.tmpl b/templates/org/licenses_edit_package.tmpl index 9f8bab5843..7da7e0cf1d 100644 --- a/templates/org/licenses_edit_package.tmpl +++ b/templates/org/licenses_edit_package.tmpl @@ -35,15 +35,7 @@

    {{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" $.SelectedChannelValues "EmptyText" (ctx.Locale.Tr "repo.licenses.all_channels") "Icon" "octicon-tag"}}

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

    diff --git a/templates/repo/licenses.tmpl b/templates/repo/licenses.tmpl index 0fd8be9788..c6836ad21e 100644 --- a/templates/repo/licenses.tmpl +++ b/templates/repo/licenses.tmpl @@ -125,15 +125,7 @@

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

    diff --git a/templates/repo/licenses_edit_package.tmpl b/templates/repo/licenses_edit_package.tmpl index 974764ada3..f478d547b5 100644 --- a/templates/repo/licenses_edit_package.tmpl +++ b/templates/repo/licenses_edit_package.tmpl @@ -35,15 +35,7 @@

    {{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" $.SelectedChannelValues "EmptyText" (ctx.Locale.Tr "repo.licenses.all_channels") "Icon" "octicon-tag"}}

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

    -- 2.52.0 From a149edccd3c4577aa16364b292dcfa4567bd85b4 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 2 Jun 2026 06:38:09 -0500 Subject: [PATCH 12/15] feat(licenses): feed visibility modes and login-required releases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add FeedVisibility field to UpdateStreamConfig with three modes: - public: full feed with download URLs (default) - no-download: version info visible but download URLs stripped - hidden: empty feed returned without a valid license key The "no-download" mode is the key commercial pattern — customers see updates exist (motivating purchase/renewal) but cannot download without a valid key. Joomla shows "update available" in admin. Applied consistently across all update feed endpoints (Joomla XML, Dolibarr JSON, WordPress JSON) via the shared validateUpdateKey() which now returns a stripDownloads flag. Also: when licensing is enabled, the release listing page requires login. Anonymous users are redirected to the login page. This prevents browsing release notes and download links without auth. Co-Authored-By: Claude Opus 4.6 (1M context) --- models/licenses/update_stream_config.go | 1 + models/migrations/v1_27/v340.go | 1 + options/locale/locale_en-US.json | 5 ++ routers/web/org/update_streams.go | 1 + routers/web/repo/release.go | 6 ++ routers/web/repo/updateserver.go | 93 ++++++++++------------ services/context/repo.go | 2 + services/updateserver/joomla.go | 11 ++- templates/org/settings/update_streams.tmpl | 10 +++ 9 files changed, 75 insertions(+), 55 deletions(-) diff --git a/models/licenses/update_stream_config.go b/models/licenses/update_stream_config.go index 8e512d1db7..a411416507 100644 --- a/models/licenses/update_stream_config.go +++ b/models/licenses/update_stream_config.go @@ -27,6 +27,7 @@ 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. diff --git a/models/migrations/v1_27/v340.go b/models/migrations/v1_27/v340.go index 2a40495781..1f26764e00 100644 --- a/models/migrations/v1_27/v340.go +++ b/models/migrations/v1_27/v340.go @@ -37,6 +37,7 @@ func (licensePackage340) TableName() string { 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"` diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index db7defb10c..61bd6e0031 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -2865,6 +2865,11 @@ "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", diff --git a/routers/web/org/update_streams.go b/routers/web/org/update_streams.go index b91a726961..fcf3de0876 100644 --- a/routers/web/org/update_streams.go +++ b/routers/web/org/update_streams.go @@ -44,6 +44,7 @@ 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"), diff --git a/routers/web/repo/release.go b/routers/web/repo/release.go index 8b2ee174b8..ea26b1709a 100644 --- a/routers/web/repo/release.go +++ b/routers/web/repo/release.go @@ -147,6 +147,12 @@ func getReleaseInfos(ctx *context.Context, opts *repo_model.FindReleasesOptions) // Releases render releases list page func Releases(ctx *context.Context) { + // When licensing is enabled, releases require login. + 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/updateserver.go b/routers/web/repo/updateserver.go index 334a566d90..ede3558d2a 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,14 +27,31 @@ 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") @@ -71,11 +89,11 @@ func validateUpdateKey(ctx *context.Context) (allowedChannels []string, ok bool) 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 +106,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 +139,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":[]}`)) @@ -159,7 +174,15 @@ func ServeWordPressJSON(ctx *context.Context) { return } - // Extract license key from query params (PUC client sends via addQueryArgFilter). + _, 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") @@ -167,40 +190,8 @@ func ServeWordPressJSON(ctx *context.Context) { if licenseKey == "" { licenseKey = ctx.FormString("key") } - - // Validate key if required. - repoCfg, _ := licenses.GetRepoConfig(ctx, ctx.Repo.Repository.ID) - requireKey := repoCfg != nil && repoCfg.RequireKey - if requireKey && licenseKey == "" { - // Return minimal info without download URL. - 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 - } - - if licenseKey != "" { - domain := ctx.FormString("domain") - if domain == "" { - domain = ctx.FormString("url") - } - key, _, err := licenses.ValidateLicenseKeyForRepo(ctx, licenseKey, domain, ctx.Repo.Repository.ID) - if err != nil { - log.Debug("WordPress update key validation failed: %v", err) - 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 - } - _ = licenses.TouchHeartbeat(ctx, key.ID) - _ = licenses.RecordUsage(ctx, &licenses.LicenseKeyUsage{ - KeyID: key.ID, - RepoID: ctx.Repo.Repository.ID, - Domain: domain, - IPAddress: ctx.RemoteAddr(), - UserAgent: ctx.Req.UserAgent(), - VersionFrom: ctx.FormString("installed_version"), - }) + if stripDownloads { + licenseKey = "" // strip download URLs by clearing the key } data, err := updateserver.GenerateWordPressJSON(ctx, ctx.Repo.Repository, licenseKey) diff --git a/services/context/repo.go b/services/context/repo.go index c13f96c704..df7cf2021b 100644 --- a/services/context/repo.go +++ b/services/context/repo.go @@ -618,6 +618,8 @@ func repoAssignmentPrepareTemplateData(ctx *Context, data *repoAssignmentPrepare ctx.Data["NumLicensePackages"] = numLicensePackages ctx.Data["EnableLicenses"] = licensingEnabled || numLicensePackages > 0 ctx.Data["LicensingEnabled"] = licensingEnabled + // When licensing is enabled, releases require login (no anonymous access). + ctx.Data["ReleasesRequireLogin"] = licensingEnabled ctx.Data["IsRepoAdmin"] = ctx.Repo.Permission.IsAdmin() ctx.Data["IsSiteAdmin"] = ctx.IsUserSiteAdmin() diff --git a/services/updateserver/joomla.go b/services/updateserver/joomla.go index f56758c973..808f644887 100644 --- a/services/updateserver/joomla.go +++ b/services/updateserver/joomla.go @@ -149,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, @@ -306,9 +306,12 @@ 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: joomlaTagName(ch)}, ChangelogURL: fmt.Sprintf("%s/changelog.xml", repoLink), diff --git a/templates/org/settings/update_streams.tmpl b/templates/org/settings/update_streams.tmpl index e68220487e..e17151d4ca 100644 --- a/templates/org/settings/update_streams.tmpl +++ b/templates/org/settings/update_streams.tmpl @@ -27,6 +27,16 @@

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

    +
    + + +

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

    +
    +
    @@ -277,6 +278,7 @@

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

    + {{$.CsrfTokenHtml}} {{template "base/modal_actions_confirm" (dict "ModalButtonDangerText" (ctx.Locale.Tr "repo.licenses.delete_key"))}}
    diff --git a/templates/repo/licenses.tmpl b/templates/repo/licenses.tmpl index c6836ad21e..a0b7a710c6 100644 --- a/templates/repo/licenses.tmpl +++ b/templates/repo/licenses.tmpl @@ -311,6 +311,7 @@

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

    + {{$.CsrfTokenHtml}}
    @@ -328,6 +329,7 @@

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

    + {{$.CsrfTokenHtml}} {{template "base/modal_actions_confirm" (dict "ModalButtonDangerText" (ctx.Locale.Tr "repo.licenses.delete_key"))}}
    -- 2.52.0